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:
gsxdsm
2026-04-10 01:42:07 -07:00
parent f00cf6e4a1
commit aa8afb82ed
10 changed files with 2430 additions and 9 deletions

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

View File

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

View File

@@ -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"

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