feat(FN-1361): add chat view with SSE streaming and session management
- Add ChatView component with sidebar navigation and message thread layout - Add useChat hook with SSE streaming state management for real-time updates - Add chat API functions for sessions and messages (create, list, send, stream) - Add mobile-responsive CSS styles for chat interface - Add tests for useChat hook and ChatView component - Integrate chat into task view and navigation (Header, MobileNavBar)
This commit is contained in:
@@ -7,6 +7,7 @@ import { ProjectOverview } from "./components/ProjectOverview";
|
||||
import { AgentsView } from "./components/AgentsView";
|
||||
import { MissionManager } from "./components/MissionManager";
|
||||
import { NodesView } from "./components/NodesView";
|
||||
import { ChatView } from "./components/ChatView";
|
||||
import { PageErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { AppModals } from "./components/AppModals";
|
||||
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
|
||||
@@ -297,6 +298,14 @@ function AppInner() {
|
||||
}
|
||||
|
||||
// Project view
|
||||
if (taskView === "chat") {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<ChatView addToast={addToast} projectId={currentProject?.id} />
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
if (taskView === "missions") {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
@@ -497,7 +506,7 @@ function AppInner() {
|
||||
onRunScript={modalManager.runScript}
|
||||
projectId={currentProject?.id}
|
||||
/>
|
||||
{viewMode === "project" && currentProject && (
|
||||
{viewMode === "project" && currentProject && taskView !== "chat" && (
|
||||
<QuickChatFAB projectId={currentProject.id} addToast={addToast} />
|
||||
)}
|
||||
<AppModals
|
||||
|
||||
@@ -35,6 +35,8 @@ import type {
|
||||
AgentRating,
|
||||
AgentRatingSummary,
|
||||
AgentRatingInput,
|
||||
ChatSession,
|
||||
ChatMessage,
|
||||
} from "@fusion/core";
|
||||
import type { PlanningQuestion, PlanningSummary, PlanningResponse } from "@fusion/core";
|
||||
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStep } from "@fusion/core";
|
||||
@@ -3881,3 +3883,221 @@ export async function reloadPlugin(id: string, projectId?: string): Promise<Plug
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Chat API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ChatSessionListResponse {
|
||||
sessions: ChatSession[];
|
||||
}
|
||||
|
||||
export interface ChatSessionResponse {
|
||||
session: ChatSession;
|
||||
}
|
||||
|
||||
export interface ChatMessageListResponse {
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
/** Fetch all chat sessions for a project */
|
||||
export function fetchChatSessions(projectId?: string, status?: string): Promise<ChatSessionListResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (projectId) search.set("projectId", projectId);
|
||||
if (status) search.set("status", status);
|
||||
const qs = search.toString();
|
||||
return api<ChatSessionListResponse>(`/chat/sessions${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
/** Create a new chat session */
|
||||
export function createChatSession(
|
||||
input: { agentId: string; title?: string; modelProvider?: string; modelId?: string },
|
||||
projectId?: string,
|
||||
): Promise<ChatSessionResponse> {
|
||||
return api<ChatSessionResponse>(withProjectId("/chat/sessions", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a single chat session */
|
||||
export function fetchChatSession(id: string, projectId?: string): Promise<ChatSessionResponse> {
|
||||
return api<ChatSessionResponse>(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId));
|
||||
}
|
||||
|
||||
/** Update a chat session (title, status) */
|
||||
export function updateChatSession(
|
||||
id: string,
|
||||
updates: { title?: string; status?: string },
|
||||
projectId?: string,
|
||||
): Promise<ChatSessionResponse> {
|
||||
return api<ChatSessionResponse>(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a chat session */
|
||||
export function deleteChatSession(id: string, projectId?: string): Promise<{ success: boolean }> {
|
||||
return api<{ success: boolean }>(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch messages for a chat session */
|
||||
export function fetchChatMessages(
|
||||
sessionId: string,
|
||||
opts?: { limit?: number; offset?: number; before?: string },
|
||||
projectId?: string,
|
||||
): Promise<ChatMessageListResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (opts?.limit !== undefined) search.set("limit", String(opts.limit));
|
||||
if (opts?.offset !== undefined) search.set("offset", String(opts.offset));
|
||||
if (opts?.before) search.set("before", opts.before);
|
||||
const qs = search.toString();
|
||||
return api<ChatMessageListResponse>(
|
||||
withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ""}`, projectId),
|
||||
);
|
||||
}
|
||||
|
||||
/** Delete a specific message from a chat session */
|
||||
export function deleteChatMessage(
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
projectId?: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
return api<{ success: boolean }>(
|
||||
withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, projectId),
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Send a chat message and receive the AI response via SSE streaming.
|
||||
*
|
||||
* The backend exposes `POST /api/chat/sessions/:id/messages` which returns an SSE
|
||||
* stream (not JSON). Events: `thinking`, `text`, `done`, `error`.
|
||||
*
|
||||
* Since `EventSource` only supports GET requests, this function uses `fetch()`
|
||||
* with a ReadableStream to parse SSE events from the POST response body.
|
||||
*/
|
||||
export function streamChatResponse(
|
||||
sessionId: string,
|
||||
content: string,
|
||||
handlers: {
|
||||
onThinking?: (data: string) => void;
|
||||
onText?: (data: string) => void;
|
||||
onDone?: (data: { messageId: string }) => void;
|
||||
onError?: (data: string) => void;
|
||||
onConnectionStateChange?: (state: StreamConnectionState) => void;
|
||||
},
|
||||
projectId?: string,
|
||||
_options?: { maxReconnectAttempts?: number },
|
||||
): { close: () => void; isConnected: () => boolean } {
|
||||
const url = buildApiUrl(withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/messages`, projectId));
|
||||
|
||||
let abortController = new AbortController();
|
||||
let closedByUser = false;
|
||||
|
||||
// Start streaming via POST
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.text();
|
||||
let errorMsg = `Request failed: ${res.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(errorBody);
|
||||
errorMsg = parsed.error || errorMsg;
|
||||
} catch { /* use default */ }
|
||||
handlers.onError?.(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.body) {
|
||||
handlers.onError?.("No response body");
|
||||
return;
|
||||
}
|
||||
|
||||
handlers.onConnectionStateChange?.("connected");
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
let currentEvent = "";
|
||||
let currentData = "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event: ")) {
|
||||
currentEvent = line.slice(7).trim();
|
||||
} else if (line.startsWith("data: ")) {
|
||||
currentData = line.slice(6);
|
||||
} else if (line === "") {
|
||||
// End of event
|
||||
if (currentEvent && currentData) {
|
||||
switch (currentEvent) {
|
||||
case "thinking":
|
||||
try {
|
||||
handlers.onThinking?.(JSON.parse(currentData));
|
||||
} catch {
|
||||
handlers.onThinking?.(currentData);
|
||||
}
|
||||
break;
|
||||
case "text":
|
||||
try {
|
||||
handlers.onText?.(JSON.parse(currentData));
|
||||
} catch {
|
||||
handlers.onText?.(currentData);
|
||||
}
|
||||
break;
|
||||
case "done":
|
||||
try {
|
||||
handlers.onDone?.(JSON.parse(currentData));
|
||||
} catch {
|
||||
handlers.onDone?.({ messageId: "" });
|
||||
}
|
||||
break;
|
||||
case "error":
|
||||
try {
|
||||
const parsed = JSON.parse(currentData);
|
||||
handlers.onError?.(parsed.message || parsed);
|
||||
} catch {
|
||||
handlers.onError?.(currentData || "Stream error");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
currentEvent = "";
|
||||
currentData = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (closedByUser) return;
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
handlers.onError?.(err instanceof Error ? err.message : "Connection error");
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
close: () => {
|
||||
closedByUser = true;
|
||||
abortController.abort();
|
||||
},
|
||||
isConnected: () => !closedByUser,
|
||||
};
|
||||
}
|
||||
|
||||
522
packages/dashboard/app/components/ChatView.tsx
Normal file
522
packages/dashboard/app/components/ChatView.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
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 { useAgents } from "../hooks/useAgents";
|
||||
import { useViewportMode } from "./Header";
|
||||
import type { Agent } from "../api";
|
||||
|
||||
export interface ChatViewProps {
|
||||
projectId?: string;
|
||||
addToast: (msg: string, type?: "success" | "error") => void;
|
||||
}
|
||||
|
||||
function getAgentLabel(agent: Agent): string {
|
||||
const base = agent.name?.trim() || agent.id;
|
||||
return `${base} (${agent.role})`;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
interface NewChatDialogProps {
|
||||
agents: Agent[];
|
||||
onClose: () => void;
|
||||
onCreate: (input: { agentId: string; title?: string; modelProvider?: string; modelId?: string }) => void;
|
||||
}
|
||||
|
||||
function NewChatDialog({ agents, onClose, onCreate }: NewChatDialogProps) {
|
||||
const [agentId, setAgentId] = useState(agents[0]?.id ?? "");
|
||||
const [title, setTitle] = useState("");
|
||||
const [modelProvider, setModelProvider] = useState("");
|
||||
const [modelId, setModelId] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!agentId) return;
|
||||
onCreate({ agentId, title: title || undefined, modelProvider: modelProvider || undefined, modelId: modelId || undefined });
|
||||
};
|
||||
|
||||
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>
|
||||
Agent
|
||||
<select
|
||||
value={agentId}
|
||||
onChange={(e) => setAgentId(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Select an agent</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{getAgentLabel(agent)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Title (optional)
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Conversation title"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Model Provider (optional)
|
||||
<input
|
||||
type="text"
|
||||
value={modelProvider}
|
||||
onChange={(e) => setModelProvider(e.target.value)}
|
||||
placeholder="e.g., anthropic"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Model ID (optional)
|
||||
<input
|
||||
type="text"
|
||||
value={modelId}
|
||||
onChange={(e) => setModelId(e.target.value)}
|
||||
placeholder="e.g., claude-sonnet-4-5"
|
||||
/>
|
||||
</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={!agentId}>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
const { agents } = useAgents(projectId);
|
||||
const {
|
||||
sessions,
|
||||
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 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]);
|
||||
|
||||
// Handle create session
|
||||
const handleCreateSession = useCallback(
|
||||
async (input: { agentId: string; title?: string; modelProvider?: string; modelId?: 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
|
||||
agents={agents}
|
||||
onClose={() => setShowNewDialog(false)}
|
||||
onCreate={handleCreateSession}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-empty-state">
|
||||
<MessageSquare size={48} strokeWidth={1.5} />
|
||||
<h2>Start a new conversation</h2>
|
||||
<div className="chat-empty-state-agent-select">
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
void handleCreateSession({ agentId: e.target.value });
|
||||
}
|
||||
}}
|
||||
value=""
|
||||
>
|
||||
<option value="">Select an agent to start chatting</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{getAgentLabel(agent)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<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>{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 || 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
|
||||
agents={agents}
|
||||
onClose={() => setShowNewDialog(false)}
|
||||
onCreate={handleCreateSession}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Server, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail } from "lucide-react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Server, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare } from "lucide-react";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import type { NodeConfig } from "@fusion/core";
|
||||
import { fetchScripts } from "../api";
|
||||
@@ -53,8 +53,8 @@ export interface HeaderProps {
|
||||
enginePaused?: boolean;
|
||||
onToggleGlobalPause?: () => void;
|
||||
onToggleEnginePause?: () => void;
|
||||
view?: "board" | "list" | "agents" | "missions";
|
||||
onChangeView?: (view: "board" | "list" | "agents" | "missions") => void;
|
||||
view?: "board" | "list" | "agents" | "missions" | "chat";
|
||||
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat") => void;
|
||||
searchQuery?: string;
|
||||
onSearchChange?: (query: string) => void;
|
||||
/** Multi-project props */
|
||||
@@ -494,6 +494,15 @@ export function Header({
|
||||
>
|
||||
<Target size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`view-toggle-btn${view === "chat" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("chat")}
|
||||
title="Chat view"
|
||||
aria-label="Chat view"
|
||||
aria-pressed={view === "chat"}
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
Settings,
|
||||
@@ -25,9 +26,9 @@ import { useViewportMode } from "./Header";
|
||||
|
||||
export interface MobileNavBarProps {
|
||||
/** Current task view mode */
|
||||
view: "board" | "list" | "agents" | "missions";
|
||||
view: "board" | "list" | "agents" | "missions" | "chat";
|
||||
/** Change task view handler */
|
||||
onChangeView: (view: "board" | "list" | "agents" | "missions") => void;
|
||||
onChangeView: (view: "board" | "list" | "agents" | "missions" | "chat") => void;
|
||||
/** Whether the ExecutorStatusBar footer is visible */
|
||||
footerVisible: boolean;
|
||||
/** Whether any full-screen modal is currently open (hides the tab bar) */
|
||||
@@ -212,6 +213,18 @@ export function MobileNavBar({
|
||||
<span className="mobile-nav-tab-label">Missions</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`mobile-nav-tab${view === "chat" ? " mobile-nav-tab--active" : ""}`}
|
||||
data-testid="mobile-nav-tab-chat"
|
||||
role="tab"
|
||||
aria-selected={view === "chat"}
|
||||
onClick={() => onChangeView("chat")}
|
||||
>
|
||||
<MessageSquare />
|
||||
<span className="mobile-nav-tab-label">Chat</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-nav-tab"
|
||||
|
||||
391
packages/dashboard/app/components/__tests__/ChatView.test.tsx
Normal file
391
packages/dashboard/app/components/__tests__/ChatView.test.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Tests for ChatView component: sidebar, session list, message thread,
|
||||
* new chat dialog, and input handling.
|
||||
*/
|
||||
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { ChatView } from "../ChatView";
|
||||
|
||||
// Mock scrollIntoView for JSDOM
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
import * as useChatModule from "../../hooks/useChat";
|
||||
import * as useAgentsModule from "../../hooks/useAgents";
|
||||
import type { Agent } from "../../api";
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock("../../hooks/useChat");
|
||||
vi.mock("../../hooks/useAgents");
|
||||
|
||||
const mockUseChat = vi.mocked(useChatModule.useChat);
|
||||
const mockUseAgents = vi.mocked(useAgentsModule.useAgents);
|
||||
|
||||
// Mock lucide-react icons - spread actual module and override specific icons
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("lucide-react")>();
|
||||
return {
|
||||
...actual,
|
||||
MessageSquare: ({ "data-testid": testId, ...props }: any) => (
|
||||
<svg data-testid={testId || "icon-message-square"} {...props} />
|
||||
),
|
||||
Send: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-send"} {...props} />,
|
||||
Plus: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-plus"} {...props} />,
|
||||
Search: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-search"} {...props} />,
|
||||
Trash2: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-trash"} {...props} />,
|
||||
Archive: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-archive"} {...props} />,
|
||||
ChevronLeft: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-chevron-left"} {...props} />,
|
||||
Bot: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-bot"} {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
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: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
];
|
||||
|
||||
const defaultChatState = {
|
||||
sessions: [],
|
||||
activeSession: null,
|
||||
sessionsLoading: false,
|
||||
messages: [],
|
||||
messagesLoading: false,
|
||||
isStreaming: false,
|
||||
streamingText: "",
|
||||
streamingThinking: "",
|
||||
selectSession: vi.fn(),
|
||||
createSession: vi.fn().mockResolvedValue({ id: "session-new", agentId: "agent-001" }),
|
||||
archiveSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
loadMoreMessages: vi.fn(),
|
||||
hasMoreMessages: false,
|
||||
searchQuery: "",
|
||||
setSearchQuery: vi.fn(),
|
||||
filteredSessions: [],
|
||||
refreshSessions: vi.fn(),
|
||||
};
|
||||
|
||||
function setupMockChat(overrides: Partial<typeof defaultChatState> = {}) {
|
||||
const state = { ...defaultChatState, ...overrides };
|
||||
mockUseChat.mockReturnValue(state as any);
|
||||
}
|
||||
|
||||
function setupMockAgents() {
|
||||
mockUseAgents.mockReturnValue({
|
||||
agents: mockAgents,
|
||||
activeAgents: mockAgents,
|
||||
stats: null,
|
||||
isLoading: false,
|
||||
loadAgents: vi.fn(),
|
||||
loadStats: vi.fn(),
|
||||
} as any);
|
||||
}
|
||||
|
||||
describe("ChatView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setupMockAgents();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders empty state when no session is selected", () => {
|
||||
setupMockChat({ sessions: [] });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("Start a new conversation")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chat-new-btn")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders session list in sidebar", () => {
|
||||
setupMockChat({
|
||||
sessions: [
|
||||
{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
{ id: "session-002", agentId: "agent-002", status: "active", title: "Another Chat", updatedAt: "2026-04-07T00:00:00.000Z" },
|
||||
],
|
||||
filteredSessions: [
|
||||
{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
{ id: "session-002", agentId: "agent-002", status: "active", title: "Another Chat", updatedAt: "2026-04-07T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("Test Chat")).toBeInTheDocument();
|
||||
expect(screen.getByText("Another Chat")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls selectSession when clicking a session", async () => {
|
||||
const selectSession = vi.fn();
|
||||
setupMockChat({
|
||||
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
selectSession,
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Test Chat"));
|
||||
|
||||
expect(selectSession).toHaveBeenCalledWith("session-001");
|
||||
});
|
||||
|
||||
it("highlights active session", () => {
|
||||
setupMockChat({
|
||||
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const sessionItem = screen.getByTestId("chat-session-session-001");
|
||||
expect(sessionItem).toHaveClass("chat-session-item--active");
|
||||
});
|
||||
|
||||
it("opens new chat dialog when clicking New Chat button", async () => {
|
||||
setupMockChat({ sessions: [], filteredSessions: [] });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
// Click the sidebar New Chat button
|
||||
await userEvent.click(screen.getByTestId("chat-new-btn"));
|
||||
|
||||
// Dialog should be open - check for dialog content
|
||||
const dialog = document.querySelector(".chat-new-dialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(within(dialog!).getByText("Agent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates session and closes dialog", async () => {
|
||||
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "agent-001" });
|
||||
setupMockChat({ sessions: [], filteredSessions: [], createSession });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
await userEvent.click(screen.getByTestId("chat-new-btn"));
|
||||
|
||||
const dialog = document.querySelector(".chat-new-dialog");
|
||||
const select = within(dialog!).getByRole("combobox") as HTMLSelectElement;
|
||||
await userEvent.selectOptions(select, "agent-001");
|
||||
|
||||
await userEvent.click(within(dialog!).getByText("Create"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSession).toHaveBeenCalledWith({
|
||||
agentId: "agent-001",
|
||||
title: undefined,
|
||||
modelProvider: undefined,
|
||||
modelId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("renders messages for active session", () => {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
messages: [
|
||||
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
|
||||
{ id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi there!", createdAt: "2026-04-08T00:01:00.000Z" },
|
||||
],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("Hello")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hi there!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends message on Enter key", async () => {
|
||||
const sendMessage = vi.fn();
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
messages: [],
|
||||
sendMessage,
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
await userEvent.type(textarea, "Hello world{enter}");
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith("Hello world");
|
||||
});
|
||||
|
||||
it("does not send on Shift+Enter", async () => {
|
||||
const sendMessage = vi.fn();
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
messages: [],
|
||||
sendMessage,
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
await userEvent.type(textarea, "Hello world{Shift>}{Enter}{/Shift}");
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables send button when input is empty", () => {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
messages: [],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const sendButton = screen.getByTestId("chat-send-btn");
|
||||
expect(sendButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables send button when streaming", () => {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
messages: [],
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const sendButton = screen.getByTestId("chat-send-btn");
|
||||
expect(sendButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows streaming indicator when isStreaming is true", () => {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
messages: [
|
||||
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
|
||||
],
|
||||
isStreaming: true,
|
||||
streamingText: "Typing...",
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
// Streaming message should show
|
||||
const streamingMessage = document.querySelector(".chat-message--streaming");
|
||||
expect(streamingMessage).toBeInTheDocument();
|
||||
expect(streamingMessage?.textContent).toContain("Typing");
|
||||
});
|
||||
|
||||
it("shows thinking blocks collapsed by default", () => {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
messages: [
|
||||
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Here's my response", thinkingOutput: "I need to think about this...", createdAt: "2026-04-08T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const details = screen.getByText("Here's my response").parentElement?.querySelector("details");
|
||||
expect(details).toBeInTheDocument();
|
||||
expect(details).toHaveProperty("open", false);
|
||||
});
|
||||
|
||||
it("filters sessions by search query", async () => {
|
||||
setupMockChat({
|
||||
sessions: [
|
||||
{ id: "session-001", agentId: "agent-001", status: "active", title: "Frontend work", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
{ id: "session-002", agentId: "agent-002", status: "active", title: "Backend API", updatedAt: "2026-04-07T00:00:00.000Z" },
|
||||
],
|
||||
filteredSessions: [
|
||||
{ id: "session-001", agentId: "agent-001", status: "active", title: "Frontend work", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
],
|
||||
searchQuery: "frontend",
|
||||
setSearchQuery: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("Frontend work")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Backend API")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state agent selector and Start Chat button", () => {
|
||||
setupMockChat({ sessions: [], filteredSessions: [] });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("Start a new conversation")).toBeInTheDocument();
|
||||
// Find the New Chat button in the empty state section
|
||||
const emptyState = document.querySelector(".chat-empty-state");
|
||||
expect(within(emptyState!).getByRole("button", { name: /new chat/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows context menu on right-click", async () => {
|
||||
setupMockChat({
|
||||
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const sessionItem = screen.getByTestId("chat-session-session-001");
|
||||
|
||||
await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" });
|
||||
|
||||
expect(screen.getByTestId("chat-context-archive")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chat-context-delete")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls archiveSession when clicking Archive in context menu", async () => {
|
||||
const archiveSession = vi.fn();
|
||||
setupMockChat({
|
||||
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
archiveSession,
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const sessionItem = screen.getByTestId("chat-session-session-001");
|
||||
await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" });
|
||||
|
||||
await userEvent.click(screen.getByTestId("chat-context-archive"));
|
||||
|
||||
expect(archiveSession).toHaveBeenCalledWith("session-001");
|
||||
});
|
||||
|
||||
it("shows delete confirmation dialog", async () => {
|
||||
setupMockChat({
|
||||
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const sessionItem = screen.getByTestId("chat-session-session-001");
|
||||
await userEvent.pointer({ target: sessionItem, keys: "[MouseRight]" });
|
||||
|
||||
await userEvent.click(screen.getByTestId("chat-context-delete"));
|
||||
|
||||
// Dialog should be open
|
||||
const dialog = document.querySelector(".chat-new-dialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(within(dialog!).getByText("Delete Conversation?")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
467
packages/dashboard/app/hooks/__tests__/useChat.test.ts
Normal file
467
packages/dashboard/app/hooks/__tests__/useChat.test.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* Tests for useChat hook: session management, message loading, SSE streaming,
|
||||
* search/filter, and pagination.
|
||||
*/
|
||||
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useChat } from "../useChat";
|
||||
import * as apiModule from "../../api";
|
||||
import type { ChatSession, ChatMessage } from "@fusion/core";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchChatSessions: vi.fn(),
|
||||
createChatSession: vi.fn(),
|
||||
fetchChatMessages: vi.fn(),
|
||||
updateChatSession: vi.fn(),
|
||||
deleteChatSession: vi.fn(),
|
||||
streamChatResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
|
||||
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
|
||||
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
|
||||
const mockUpdateChatSession = vi.mocked(apiModule.updateChatSession);
|
||||
const mockDeleteChatSession = vi.mocked(apiModule.deleteChatSession);
|
||||
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
|
||||
|
||||
function makeSession(overrides: Partial<ChatSession> & Pick<ChatSession, "id" | "agentId">): ChatSession {
|
||||
return {
|
||||
id: overrides.id,
|
||||
agentId: overrides.agentId,
|
||||
status: overrides.status ?? "active",
|
||||
title: overrides.title,
|
||||
modelProvider: overrides.modelProvider,
|
||||
modelId: overrides.modelId,
|
||||
createdAt: overrides.createdAt ?? "2026-04-08T00:00:00.000Z",
|
||||
updatedAt: overrides.updatedAt ?? "2026-04-08T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessage(overrides: Partial<ChatMessage> & Pick<ChatMessage, "id" | "sessionId" | "role" | "content">): ChatMessage {
|
||||
return {
|
||||
id: overrides.id,
|
||||
sessionId: overrides.sessionId,
|
||||
role: overrides.role,
|
||||
content: overrides.content,
|
||||
thinkingOutput: overrides.thinkingOutput,
|
||||
createdAt: overrides.createdAt ?? "2026-04-08T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("useChat", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchChatSessions.mockResolvedValue({ sessions: [] });
|
||||
mockCreateChatSession.mockResolvedValue({
|
||||
session: makeSession({ id: "session-001", agentId: "agent-001", title: "New Chat" }),
|
||||
});
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
mockUpdateChatSession.mockResolvedValue({
|
||||
session: makeSession({ id: "session-001", agentId: "agent-001", status: "archived" }),
|
||||
});
|
||||
mockDeleteChatSession.mockResolvedValue({ success: true });
|
||||
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads sessions on mount", async () => {
|
||||
mockFetchChatSessions.mockResolvedValueOnce({
|
||||
sessions: [
|
||||
makeSession({ id: "session-001", agentId: "agent-001" }),
|
||||
makeSession({ id: "session-002", agentId: "agent-002" }),
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChat("proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchChatSessions).toHaveBeenCalledWith("proj-123");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(result.current.sessions[0]?.id).toBe("session-001");
|
||||
expect(result.current.sessions[1]?.id).toBe("session-002");
|
||||
});
|
||||
|
||||
it("selects a session and loads its messages", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatMessages.mockResolvedValueOnce({
|
||||
messages: [
|
||||
makeMessage({ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello" }),
|
||||
makeMessage({ id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi there" }),
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession("session-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchChatMessages).toHaveBeenCalledWith("session-001", { limit: 50 }, undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messages).toHaveLength(2);
|
||||
expect(result.current.activeSession?.id).toBe("session-001");
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a new session and selects it", async () => {
|
||||
const newSession = makeSession({ id: "session-new", agentId: "agent-001", title: "Test Chat" });
|
||||
mockCreateChatSession.mockResolvedValueOnce({ session: newSession });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [] });
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessionsLoading).toBe(false);
|
||||
});
|
||||
|
||||
let createdSession: ReturnType<typeof result.current.createSession> extends Promise<infer T> ? T : never;
|
||||
await act(async () => {
|
||||
createdSession = await result.current.createSession({
|
||||
agentId: "agent-001",
|
||||
title: "Test Chat",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateChatSession).toHaveBeenCalledWith(
|
||||
{ agentId: "agent-001", title: "Test Chat" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeSession?.id).toBe("session-new");
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("archives a session", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.archiveSession("session-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateChatSession).toHaveBeenCalledWith("session-001", { status: "archived" }, undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a session", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteSession("session-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteChatSession).toHaveBeenCalledWith("session-001", undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a message and receives streaming response", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
|
||||
|
||||
// Track stream close call
|
||||
const closeFn = vi.fn();
|
||||
let textHandler: ((data: string) => void) | undefined;
|
||||
let doneHandler: ((data: { messageId: string }) => void) | undefined;
|
||||
|
||||
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||
textHandler = handlers.onText;
|
||||
doneHandler = handlers.onDone;
|
||||
return { close: closeFn, isConnected: () => true };
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession("session-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Simulate sending a message
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Hello!");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Optimistic user message should be added
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]?.role).toBe("user");
|
||||
expect(result.current.messages[0]?.content).toBe("Hello!");
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
// Simulate streaming text
|
||||
await act(async () => {
|
||||
textHandler?.("Hello ");
|
||||
textHandler?.("there!");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.streamingText).toBe("Hello there!");
|
||||
});
|
||||
|
||||
// Simulate completion
|
||||
await act(async () => {
|
||||
doneHandler?.({ messageId: "msg-002" });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]?.role).toBe("assistant");
|
||||
expect(result.current.messages[0]?.id).toBe("msg-002");
|
||||
expect(result.current.streamingText).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles stream errors", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatMessages.mockResolvedValueOnce({ messages: [] });
|
||||
|
||||
let errorHandler: ((data: string) => void) | undefined;
|
||||
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||
errorHandler = handlers.onError;
|
||||
return { close: vi.fn(), isConnected: () => true };
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession("session-001");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Hello!");
|
||||
});
|
||||
|
||||
// Simulate error
|
||||
await act(async () => {
|
||||
errorHandler?.("Stream connection failed");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(result.current.messages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("loads more messages with pagination", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
|
||||
// Return 50 messages for initial load to keep hasMoreMessages=true, then 1 for loadMore
|
||||
const make50Messages = () =>
|
||||
Array.from({ length: 50 }, (_, i) => makeMessage({ id: `msg-${i}`, sessionId: "session-001", role: "user", content: `Message ${i}` }));
|
||||
|
||||
mockFetchChatMessages
|
||||
.mockResolvedValueOnce({ messages: make50Messages() })
|
||||
.mockResolvedValueOnce({ messages: [makeMessage({ id: "msg-old", sessionId: "session-001", role: "user", content: "Old message" })] });
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession("session-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messages).toHaveLength(50);
|
||||
expect(result.current.hasMoreMessages).toBe(true);
|
||||
});
|
||||
|
||||
// Before loadMoreMessages
|
||||
const callCountBefore = mockFetchChatMessages.mock.calls.length;
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMoreMessages();
|
||||
});
|
||||
|
||||
// Verify that loadMoreMessages triggered a new fetch
|
||||
await waitFor(() => {
|
||||
expect(mockFetchChatMessages.mock.calls.length).toBeGreaterThan(callCountBefore);
|
||||
});
|
||||
|
||||
// Verify the second call had pagination params
|
||||
const secondCall = mockFetchChatMessages.mock.calls[1];
|
||||
expect(secondCall[0]).toBe("session-001");
|
||||
expect(secondCall[1]).toHaveProperty("limit");
|
||||
expect(secondCall[1]).toHaveProperty("offset");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messages).toHaveLength(51);
|
||||
});
|
||||
});
|
||||
|
||||
it("sets hasMoreMessages to false when fewer messages returned", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||
mockFetchChatMessages.mockResolvedValueOnce({ messages: [makeMessage({ id: "msg-001", sessionId: "session-001", role: "user", content: "Recent" })] });
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession("session-001");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.hasMoreMessages).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("filters sessions by search query", async () => {
|
||||
mockFetchChatSessions.mockResolvedValueOnce({
|
||||
sessions: [
|
||||
makeSession({ id: "session-001", agentId: "agent-001", title: "Frontend work" }),
|
||||
makeSession({ id: "session-002", agentId: "agent-002", title: "Backend API" }),
|
||||
makeSession({ id: "session-003", agentId: "agent-003", title: "Frontend design" }),
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(3);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery("frontend");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.filteredSessions).toHaveLength(2);
|
||||
expect(result.current.filteredSessions.map((s) => s.id)).toContain("session-001");
|
||||
expect(result.current.filteredSessions.map((s) => s.id)).toContain("session-003");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setSearchQuery("");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.filteredSessions).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
it("closes stream when switching sessions", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
const session2 = makeSession({ id: "session-002", agentId: "agent-002" });
|
||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session, session2] });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
|
||||
const closeFn = vi.fn();
|
||||
mockStreamChatResponse.mockReturnValue({ close: closeFn, isConnected: () => true });
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectSession("session-001");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage("Hello!");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
// Switch sessions
|
||||
act(() => {
|
||||
result.current.selectSession("session-002");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(closeFn).toHaveBeenCalled();
|
||||
expect(result.current.activeSession?.id).toBe("session-002");
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes sessions", async () => {
|
||||
mockFetchChatSessions
|
||||
.mockResolvedValueOnce({ sessions: [makeSession({ id: "session-001", agentId: "agent-001" })] })
|
||||
.mockResolvedValueOnce({ sessions: [makeSession({ id: "session-001", agentId: "agent-001" }), makeSession({ id: "session-002", agentId: "agent-002" })] });
|
||||
|
||||
const { result } = renderHook(() => useChat());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshSessions();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
353
packages/dashboard/app/hooks/useChat.ts
Normal file
353
packages/dashboard/app/hooks/useChat.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
fetchChatSessions,
|
||||
createChatSession as apiCreateChatSession,
|
||||
fetchChatMessages,
|
||||
updateChatSession,
|
||||
deleteChatSession,
|
||||
streamChatResponse,
|
||||
type ChatSessionListResponse,
|
||||
} from "../api";
|
||||
|
||||
export interface ChatSessionInfo {
|
||||
id: string;
|
||||
title?: string;
|
||||
agentId: string;
|
||||
status: string;
|
||||
modelProvider?: string;
|
||||
modelId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastMessagePreview?: string;
|
||||
lastMessageAt?: string;
|
||||
}
|
||||
|
||||
export interface ChatMessageInfo {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
thinkingOutput?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UseChatReturn {
|
||||
// Session state
|
||||
sessions: ChatSessionInfo[];
|
||||
activeSession: ChatSessionInfo | null;
|
||||
sessionsLoading: boolean;
|
||||
|
||||
// Message state
|
||||
messages: ChatMessageInfo[];
|
||||
messagesLoading: boolean;
|
||||
isStreaming: boolean;
|
||||
streamingText: string;
|
||||
streamingThinking: string;
|
||||
|
||||
// Session operations
|
||||
selectSession: (id: string) => void;
|
||||
createSession: (
|
||||
input: { agentId: string; title?: string; modelProvider?: string; modelId?: string },
|
||||
) => Promise<ChatSessionInfo>;
|
||||
archiveSession: (id: string) => Promise<void>;
|
||||
deleteSession: (id: string) => Promise<void>;
|
||||
|
||||
// Message operations
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
loadMoreMessages: () => Promise<void>;
|
||||
hasMoreMessages: boolean;
|
||||
|
||||
// Search/filter
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
filteredSessions: ChatSessionInfo[];
|
||||
|
||||
// Refresh
|
||||
refreshSessions: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useChat(projectId?: string): UseChatReturn {
|
||||
// Session state
|
||||
const [sessions, setSessions] = useState<ChatSessionInfo[]>([]);
|
||||
const [activeSession, setActiveSession] = useState<ChatSessionInfo | null>(null);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true);
|
||||
|
||||
// Message state
|
||||
const [messages, setMessages] = useState<ChatMessageInfo[]>([]);
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingText, setStreamingText] = useState("");
|
||||
const [streamingThinking, setStreamingThinking] = useState("");
|
||||
|
||||
// Search/filter
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Pagination
|
||||
const [hasMoreMessages, setHasMoreMessages] = useState(true);
|
||||
|
||||
// Stream connection ref for cleanup
|
||||
const streamRef = useRef<{ close: () => void } | null>(null);
|
||||
|
||||
// Fetch sessions
|
||||
const refreshSessions = useCallback(async () => {
|
||||
setSessionsLoading(true);
|
||||
try {
|
||||
const data: ChatSessionListResponse = await fetchChatSessions(projectId);
|
||||
// Sort by updatedAt descending
|
||||
const sorted = [...data.sessions].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
setSessions(sorted);
|
||||
} catch {
|
||||
// Silently fail on refresh
|
||||
} finally {
|
||||
setSessionsLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
refreshSessions();
|
||||
}, [refreshSessions]);
|
||||
|
||||
// Load messages when active session changes
|
||||
const loadMessages = useCallback(
|
||||
async (sessionId: string, opts?: { offset?: number }) => {
|
||||
setMessagesLoading(true);
|
||||
try {
|
||||
const data = await fetchChatMessages(sessionId, { limit: 50, ...opts }, projectId);
|
||||
if (opts?.offset && opts.offset > 0) {
|
||||
// Prepend older messages
|
||||
setMessages((prev) => [...data.messages.reverse(), ...prev]);
|
||||
} else {
|
||||
setMessages(data.messages.reverse());
|
||||
}
|
||||
setHasMoreMessages(data.messages.length >= 50);
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
// Select a session
|
||||
const selectSession = useCallback(
|
||||
(id: string) => {
|
||||
// Close any existing stream
|
||||
if (streamRef.current) {
|
||||
streamRef.current.close();
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Find and set active session
|
||||
const session = sessions.find((s) => s.id === id);
|
||||
setActiveSession(session || null);
|
||||
|
||||
// Reset streaming state
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setIsStreaming(false);
|
||||
setHasMoreMessages(true);
|
||||
|
||||
// Load messages for this session
|
||||
if (id) {
|
||||
loadMessages(id);
|
||||
} else {
|
||||
setMessages([]);
|
||||
}
|
||||
},
|
||||
[sessions, loadMessages],
|
||||
);
|
||||
|
||||
// Create a new session
|
||||
const createSession = useCallback(
|
||||
async (input: { agentId: string; title?: string; modelProvider?: string; modelId?: string }) => {
|
||||
const data = await apiCreateChatSession(input, projectId);
|
||||
const newSession: ChatSessionInfo = {
|
||||
id: data.session.id,
|
||||
title: data.session.title,
|
||||
agentId: data.session.agentId,
|
||||
status: data.session.status,
|
||||
modelProvider: data.session.modelProvider,
|
||||
modelId: data.session.modelId,
|
||||
createdAt: data.session.createdAt,
|
||||
updatedAt: data.session.updatedAt,
|
||||
};
|
||||
|
||||
// Add to sessions list at the top
|
||||
setSessions((prev) => [newSession, ...prev]);
|
||||
|
||||
// Select the new session
|
||||
setActiveSession(newSession);
|
||||
setMessages([]);
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setIsStreaming(false);
|
||||
setHasMoreMessages(true);
|
||||
|
||||
return newSession;
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
// Archive a session
|
||||
const archiveSession = useCallback(
|
||||
async (id: string) => {
|
||||
await updateChatSession(id, { status: "archived" }, projectId);
|
||||
// Remove from sessions list
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
// If it was the active session, clear it
|
||||
if (activeSession?.id === id) {
|
||||
setActiveSession(null);
|
||||
setMessages([]);
|
||||
}
|
||||
},
|
||||
[activeSession, projectId],
|
||||
);
|
||||
|
||||
// Delete a session
|
||||
const deleteSession = useCallback(
|
||||
async (id: string) => {
|
||||
// Close stream if active
|
||||
if (activeSession?.id === id && streamRef.current) {
|
||||
streamRef.current.close();
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
await deleteChatSession(id, projectId);
|
||||
// Remove from sessions list
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
// If it was the active session, clear it
|
||||
if (activeSession?.id === id) {
|
||||
setActiveSession(null);
|
||||
setMessages([]);
|
||||
}
|
||||
},
|
||||
[activeSession, projectId],
|
||||
);
|
||||
|
||||
// Load more messages (pagination)
|
||||
const loadMoreMessages = useCallback(async () => {
|
||||
if (!activeSession || !hasMoreMessages) return;
|
||||
await loadMessages(activeSession.id, { offset: messages.length });
|
||||
}, [activeSession, hasMoreMessages, loadMessages, messages.length]);
|
||||
|
||||
// Send a message
|
||||
const sendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!activeSession) return;
|
||||
|
||||
// Close any existing stream
|
||||
if (streamRef.current) {
|
||||
streamRef.current.close();
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Optimistically add user message
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
const userMessage: ChatMessageInfo = {
|
||||
id: tempId,
|
||||
sessionId: activeSession.id,
|
||||
role: "user",
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
|
||||
// Clear streaming state
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setIsStreaming(true);
|
||||
|
||||
// Accumulate streaming text in local variables
|
||||
let capturedText = "";
|
||||
let capturedThinking = "";
|
||||
|
||||
const textHandlers = {
|
||||
onThinking: (data: string) => {
|
||||
capturedThinking += data;
|
||||
setStreamingThinking(capturedThinking);
|
||||
},
|
||||
onText: (data: string) => {
|
||||
capturedText += data;
|
||||
setStreamingText(capturedText);
|
||||
},
|
||||
onDone: (data: { messageId: string }) => {
|
||||
const assistantMessage: ChatMessageInfo = {
|
||||
id: data.messageId || `msg-${Date.now()}`,
|
||||
sessionId: activeSession.id,
|
||||
role: "assistant",
|
||||
content: capturedText,
|
||||
thinkingOutput: capturedThinking,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => {
|
||||
const withoutTemp = prev.filter((m) => m.id !== tempId);
|
||||
return [...withoutTemp, assistantMessage];
|
||||
});
|
||||
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setIsStreaming(false);
|
||||
streamRef.current = null;
|
||||
refreshSessions();
|
||||
},
|
||||
onError: (data: string) => {
|
||||
setMessages((prev) => prev.filter((m) => m.id !== tempId));
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setIsStreaming(false);
|
||||
streamRef.current = null;
|
||||
console.error("[useChat] Stream error:", data);
|
||||
},
|
||||
};
|
||||
|
||||
streamRef.current = streamChatResponse(activeSession.id, content, textHandlers, projectId);
|
||||
},
|
||||
[activeSession, projectId, refreshSessions],
|
||||
);
|
||||
|
||||
// Filter sessions based on search query
|
||||
const filteredSessions = searchQuery
|
||||
? sessions.filter(
|
||||
(s) =>
|
||||
s.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.agentId.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: sessions;
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.close();
|
||||
streamRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
activeSession,
|
||||
sessionsLoading,
|
||||
messages,
|
||||
messagesLoading,
|
||||
isStreaming,
|
||||
streamingText,
|
||||
streamingThinking,
|
||||
selectSession,
|
||||
createSession,
|
||||
archiveSession,
|
||||
deleteSession,
|
||||
sendMessage,
|
||||
loadMoreMessages,
|
||||
hasMoreMessages,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filteredSessions,
|
||||
refreshSessions,
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { ProjectInfo } from "../api";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
|
||||
export type ViewMode = "overview" | "project";
|
||||
export type TaskView = "board" | "list" | "agents" | "missions";
|
||||
export type TaskView = "board" | "list" | "agents" | "missions" | "chat";
|
||||
|
||||
interface UseViewStateOptions {
|
||||
projectsLoading: boolean;
|
||||
@@ -48,7 +48,7 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
|
||||
|
||||
const [taskView, setTaskView] = useState<TaskView>(() => {
|
||||
const saved = getScopedItem("kb-dashboard-task-view");
|
||||
if (saved === "board" || saved === "list" || saved === "agents" || saved === "missions") return saved;
|
||||
if (saved === "board" || saved === "list" || saved === "agents" || saved === "missions" || saved === "chat") return saved;
|
||||
return "board";
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult {
|
||||
|
||||
useEffect(() => {
|
||||
const saved = getScopedItem("kb-dashboard-task-view", currentProject?.id);
|
||||
if (saved === "board" || saved === "list" || saved === "agents" || saved === "missions") {
|
||||
if (saved === "board" || saved === "list" || saved === "agents" || saved === "missions" || saved === "chat") {
|
||||
setTaskView(saved);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24735,6 +24735,410 @@ html .column.drag-over * {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Chat View ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.chat-view {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.chat-sidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.chat-sidebar--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-sidebar-header {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-sidebar-search-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-sidebar-search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-sidebar-search {
|
||||
width: 100%;
|
||||
padding: 8px 12px 8px 32px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-new-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text, #fff);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.chat-new-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.chat-session-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.chat-session-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
transition: background 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-session-item:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.chat-session-item--active {
|
||||
background: var(--accent-bg, var(--hover));
|
||||
}
|
||||
|
||||
.chat-session-title {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-session-preview {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-session-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Context menu for session items */
|
||||
.chat-session-context-menu {
|
||||
position: fixed;
|
||||
background: var(--bg-elevated, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
padding: 4px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.chat-session-context-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-session-context-menu button:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
/* Main chat thread */
|
||||
.chat-thread {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-thread-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-thread-header-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
max-width: 75%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-message--user {
|
||||
align-self: flex-end;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text, #fff);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-message--assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--bg-elevated, var(--bg-secondary));
|
||||
color: var(--text);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-message-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chat-message-content {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-message-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chat-message-thinking {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.chat-message-thinking summary {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-message-thinking-content {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
margin-top: 4px;
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--font-mono, monospace);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Streaming indicator */
|
||||
.chat-message--streaming {
|
||||
align-self: flex-start;
|
||||
background: var(--bg-elevated, var(--bg-secondary));
|
||||
color: var(--text);
|
||||
border-bottom-left-radius: 4px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.chat-typing-indicator {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.chat-typing-indicator span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-secondary);
|
||||
animation: chat-typing-bounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.chat-typing-indicator span:nth-child(1) { animation-delay: 0s; }
|
||||
.chat-typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.chat-typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes chat-typing-bounce {
|
||||
0%, 80%, 100% { opacity: 0.3; }
|
||||
40% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Input area */
|
||||
.chat-input-area {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-input-textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
max-height: 120px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.chat-input-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-input-send {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text, #fff);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input-send:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.chat-empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
color: var(--text-secondary);
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-empty-state h2 {
|
||||
font-size: 20px;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-empty-state-agent-select select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* New chat dialog */
|
||||
.chat-new-dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.chat-new-dialog {
|
||||
background: var(--bg-elevated, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chat-new-dialog h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.chat-new-dialog label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chat-new-dialog input,
|
||||
.chat-new-dialog select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-new-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.quick-chat-fab {
|
||||
right: 16px;
|
||||
@@ -26328,3 +26732,36 @@ html .column.drag-over * {
|
||||
.btn-reset-budget {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ── Chat View Mobile Styles ────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-view {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-height: 40vh;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-sidebar--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.chat-new-dialog {
|
||||
width: 95vw;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.chat-thread-header {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user