diff --git a/web-ui/__mocks__/@hugeicons/react.js b/web-ui/__mocks__/@hugeicons/react.js index d3fa2e15..16d2bcec 100644 --- a/web-ui/__mocks__/@hugeicons/react.js +++ b/web-ui/__mocks__/@hugeicons/react.js @@ -55,4 +55,7 @@ module.exports = { ArrowDown01Icon: createIconMock('ArrowDown01Icon'), ArrowUp01Icon: createIconMock('ArrowUp01Icon'), StopIcon: createIconMock('StopIcon'), + // AgentChatPanel + ArrowRight01Icon: createIconMock('ArrowRight01Icon'), + Alert01Icon: createIconMock('Alert01Icon'), }; diff --git a/web-ui/jest.setup.js b/web-ui/jest.setup.js index 101fb230..37a7063e 100644 --- a/web-ui/jest.setup.js +++ b/web-ui/jest.setup.js @@ -1,5 +1,8 @@ import '@testing-library/jest-dom'; +// jsdom does not implement scrollIntoView +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + // Mock next/navigation jest.mock('next/navigation', () => ({ useRouter() { diff --git a/web-ui/src/__tests__/components/sessions/AgentChatPanel.test.tsx b/web-ui/src/__tests__/components/sessions/AgentChatPanel.test.tsx new file mode 100644 index 00000000..7f9389c5 --- /dev/null +++ b/web-ui/src/__tests__/components/sessions/AgentChatPanel.test.tsx @@ -0,0 +1,323 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AgentChatPanel } from '@/components/sessions/AgentChatPanel'; +import { useAgentChat } from '@/hooks/useAgentChat'; +import type { AgentChatState, ChatMessage } from '@/types'; + +jest.mock('@/hooks/useAgentChat'); + +const mockUseAgentChat = useAgentChat as jest.MockedFunction; + +// ── Helpers ───────────────────────────────────────────────────────────── + +function makeMessage(overrides: Partial): ChatMessage { + return { + id: Math.random().toString(36).slice(2), + role: 'assistant', + content: 'Hello', + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +function makeState(overrides: Partial = {}): AgentChatState { + return { + messages: [], + status: 'idle', + costUsd: 0, + inputTokens: 0, + outputTokens: 0, + error: null, + connected: true, + ...overrides, + }; +} + +const mockSendMessage = jest.fn(); +const mockInterrupt = jest.fn(); +const mockClearMessages = jest.fn(); + +function setupMock(state: AgentChatState) { + mockUseAgentChat.mockReturnValue({ + state, + sendMessage: mockSendMessage, + interrupt: mockInterrupt, + clearMessages: mockClearMessages, + }); +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe('AgentChatPanel', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ── Empty state ────────────────────────────────────────────────────── + + it('shows empty state when there are no messages', () => { + setupMock(makeState()); + render(); + expect(screen.getByText('Start a conversation with your agent')).toBeInTheDocument(); + }); + + it('does not show empty state when messages exist', () => { + setupMock(makeState({ messages: [makeMessage({ role: 'user', content: 'Hi' })] })); + render(); + expect(screen.queryByText('Start a conversation with your agent')).not.toBeInTheDocument(); + expect(screen.getByText('Hi')).toBeInTheDocument(); + }); + + // ── All 7 message roles ────────────────────────────────────────────── + + it('renders user message with correct role styling', () => { + setupMock(makeState({ messages: [makeMessage({ role: 'user', content: 'User msg' })] })); + render(); + expect(screen.getByText('User msg')).toBeInTheDocument(); + }); + + it('renders assistant message', () => { + setupMock(makeState({ messages: [makeMessage({ role: 'assistant', content: 'Assistant msg' })] })); + render(); + expect(screen.getByText('Assistant msg')).toBeInTheDocument(); + }); + + it('renders tool_use card collapsed by default with tool name', () => { + setupMock(makeState({ + messages: [makeMessage({ + role: 'tool_use', + content: '', + toolName: 'read_file', + toolInput: { path: 'src/index.ts' }, + })], + })); + render(); + expect(screen.getByText(/read_file/)).toBeInTheDocument(); + // JSON body should not be visible when collapsed + expect(screen.queryByText(/"path"/)).not.toBeInTheDocument(); + }); + + it('expands tool_use card when clicked', () => { + setupMock(makeState({ + messages: [makeMessage({ + role: 'tool_use', + content: '', + toolName: 'read_file', + toolInput: { path: 'src/index.ts' }, + })], + })); + render(); + const toggle = screen.getByRole('button', { name: /expand/i }); + fireEvent.click(toggle); + expect(screen.getByText(/"path"/)).toBeInTheDocument(); + }); + + it('renders tool_result with first 200 chars visible', () => { + const longContent = 'x'.repeat(300); + setupMock(makeState({ + messages: [makeMessage({ role: 'tool_result', content: longContent })], + })); + render(); + expect(screen.getByText(/^x{200}$/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /show more/i })).toBeInTheDocument(); + }); + + it('expands tool_result when Show more clicked', () => { + const longContent = 'y'.repeat(300); + setupMock(makeState({ + messages: [makeMessage({ role: 'tool_result', content: longContent })], + })); + render(); + fireEvent.click(screen.getByRole('button', { name: /show more/i })); + expect(screen.getByText(new RegExp(`y{300}`))).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /show less/i })).toBeInTheDocument(); + }); + + it('renders thinking block with content', () => { + setupMock(makeState({ + messages: [makeMessage({ role: 'thinking', content: 'I need to check this' })], + })); + render(); + expect(screen.getByText('I need to check this')).toBeInTheDocument(); + }); + + it('renders system message', () => { + setupMock(makeState({ + messages: [makeMessage({ role: 'system', content: 'Session started' })], + })); + render(); + expect(screen.getByText('Session started')).toBeInTheDocument(); + }); + + it('renders error card', () => { + setupMock(makeState({ + messages: [makeMessage({ role: 'error', content: 'Something went wrong' })], + })); + render(); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + + // ── Streaming cursor ───────────────────────────────────────────────── + + it('shows streaming cursor on last assistant message when status is streaming', () => { + const msgs = [makeMessage({ role: 'assistant', content: 'Typing...' })]; + setupMock(makeState({ messages: msgs, status: 'streaming' })); + render(); + expect(screen.getByTestId('streaming-cursor')).toBeInTheDocument(); + }); + + it('does not show streaming cursor when status is idle', () => { + const msgs = [makeMessage({ role: 'assistant', content: 'Done' })]; + setupMock(makeState({ messages: msgs, status: 'idle' })); + render(); + expect(screen.queryByTestId('streaming-cursor')).not.toBeInTheDocument(); + }); + + it('scrollIntoView called when last message content updates (streaming in-place)', () => { + const msg = makeMessage({ role: 'assistant', content: 'Hello' }); + const { rerender } = render( + + ); + // Initial render with one message + setupMock(makeState({ messages: [msg], status: 'streaming' })); + rerender(); + + // Simulate in-place content update (same id, different content) + const updatedMsg = { ...msg, content: 'Hello world' }; + setupMock(makeState({ messages: [updatedMsg], status: 'streaming' })); + rerender(); + + expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled(); + }); + + // ── Input bar ──────────────────────────────────────────────────────── + + it('input textarea is enabled when status is idle', () => { + setupMock(makeState({ status: 'idle' })); + render(); + expect(screen.getByRole('textbox', { name: /message/i })).not.toBeDisabled(); + }); + + it('input textarea is disabled while thinking', () => { + setupMock(makeState({ status: 'thinking' })); + render(); + expect(screen.getByRole('textbox', { name: /message/i })).toBeDisabled(); + }); + + it('input textarea is disabled while streaming', () => { + setupMock(makeState({ status: 'streaming' })); + render(); + expect(screen.getByRole('textbox', { name: /message/i })).toBeDisabled(); + }); + + it('calls sendMessage and clears input on Enter', async () => { + setupMock(makeState({ status: 'idle' })); + render(); + const textarea = screen.getByRole('textbox', { name: /message/i }); + await userEvent.type(textarea, 'Hello agent'); + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + expect(mockSendMessage).toHaveBeenCalledWith('Hello agent'); + expect(textarea).toHaveValue(''); + }); + + it('does not send on Shift+Enter', async () => { + setupMock(makeState({ status: 'idle' })); + render(); + const textarea = screen.getByRole('textbox', { name: /message/i }); + await userEvent.type(textarea, 'Hello'); + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true }); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); + + it('calls sendMessage when Send button clicked', async () => { + setupMock(makeState({ status: 'idle' })); + render(); + const textarea = screen.getByRole('textbox', { name: /message/i }); + await userEvent.type(textarea, 'Send this'); + fireEvent.click(screen.getByRole('button', { name: /send message/i })); + expect(mockSendMessage).toHaveBeenCalledWith('Send this'); + }); + + it('does not call sendMessage when input is empty', () => { + setupMock(makeState({ status: 'idle' })); + render(); + fireEvent.click(screen.getByRole('button', { name: /send message/i })); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); + + // ── Interrupt button ───────────────────────────────────────────────── + + it('shows interrupt button during thinking', () => { + setupMock(makeState({ status: 'thinking' })); + render(); + expect(screen.getByRole('button', { name: /interrupt agent/i })).toBeInTheDocument(); + }); + + it('shows interrupt button during streaming', () => { + setupMock(makeState({ status: 'streaming' })); + render(); + expect(screen.getByRole('button', { name: /interrupt agent/i })).toBeInTheDocument(); + }); + + it('hides interrupt button when idle', () => { + setupMock(makeState({ status: 'idle' })); + render(); + expect(screen.queryByRole('button', { name: /interrupt agent/i })).not.toBeInTheDocument(); + }); + + it('calls interrupt when interrupt button clicked', () => { + setupMock(makeState({ status: 'thinking' })); + render(); + fireEvent.click(screen.getByRole('button', { name: /interrupt agent/i })); + expect(mockInterrupt).toHaveBeenCalled(); + }); + + // ── Header ─────────────────────────────────────────────────────────── + + it('shows cost in header', () => { + setupMock(makeState({ costUsd: 0.0031 })); + render(); + expect(screen.getByText('$0.0031')).toBeInTheDocument(); + }); + + it('shows green status dot when connected and idle', () => { + setupMock(makeState({ status: 'idle', connected: true })); + render(); + const dot = screen.getByRole('status', { hidden: true }); + expect(dot).toHaveClass('bg-green-500'); + }); + + it('shows yellow status dot when connecting', () => { + setupMock(makeState({ status: 'connecting', connected: false })); + render(); + const dot = screen.getByRole('status', { hidden: true }); + expect(dot).toHaveClass('bg-yellow-400'); + }); + + it('shows red status dot when disconnected', () => { + setupMock(makeState({ status: 'disconnected', connected: false })); + render(); + const dot = screen.getByRole('status', { hidden: true }); + expect(dot).toHaveClass('bg-red-500'); + }); + + // ── Accessibility ──────────────────────────────────────────────────── + + it('message log has role=log and aria-live=polite', () => { + setupMock(makeState()); + render(); + const log = screen.getByRole('log'); + expect(log).toHaveAttribute('aria-live', 'polite'); + }); + + it('tool_use toggle button has aria-expanded', () => { + setupMock(makeState({ + messages: [makeMessage({ role: 'tool_use', content: '', toolName: 'read_file', toolInput: {} })], + })); + render(); + const toggle = screen.getByRole('button', { name: /expand/i }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + }); +}); diff --git a/web-ui/src/components/sessions/AgentChatPanel.tsx b/web-ui/src/components/sessions/AgentChatPanel.tsx new file mode 100644 index 00000000..342fda88 --- /dev/null +++ b/web-ui/src/components/sessions/AgentChatPanel.tsx @@ -0,0 +1,334 @@ +'use client'; + +import { useRef, useEffect, useState, useCallback } from 'react'; +import { + ArtificialIntelligence01Icon, + ArrowRight01Icon, + Alert01Icon, + Idea01Icon, + Cancel01Icon, + SentIcon, +} from '@hugeicons/react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { useAgentChat } from '@/hooks/useAgentChat'; +import type { ChatMessage, AgentChatStatus } from '@/types'; + +// ── Types ──────────────────────────────────────────────────────────────── + +interface AgentChatPanelProps { + sessionId: string; + className?: string; +} + +// ── Status dot ─────────────────────────────────────────────────────────── + +function statusDotClass(status: AgentChatStatus): string { + if (status === 'connecting') return 'bg-yellow-400'; + if (status === 'error' || status === 'disconnected') return 'bg-red-500'; + return 'bg-green-500'; +} + +function statusLabel(status: AgentChatStatus): string { + if (status === 'connecting') return 'Status: connecting'; + if (status === 'error') return 'Status: error'; + if (status === 'disconnected') return 'Status: disconnected'; + return 'Status: connected'; +} + +// ── Per-role renderers ──────────────────────────────────────────────────── + +function UserBubble({ message }: { message: ChatMessage }) { + return ( +
+
+ {message.content} +
+
+ ); +} + +function AssistantBubble({ + message, + isLast, + status, +}: { + message: ChatMessage; + isLast: boolean; + status: AgentChatStatus; +}) { + return ( +
+
+ +
+
+ {message.content} + {isLast && status === 'streaming' && ( +
+
+ ); +} + +function ToolUseCard({ message }: { message: ChatMessage }) { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + {expanded && message.toolInput !== undefined && ( +
+          {JSON.stringify(message.toolInput, null, 2)}
+        
+ )} +
+ ); +} + +function ToolResultCard({ message }: { message: ChatMessage }) { + const [expanded, setExpanded] = useState(false); + const isTruncated = message.content.length > 200; + const displayContent = expanded ? message.content : message.content.slice(0, 200); + + return ( +
+
+        {displayContent}
+      
+ {isTruncated && ( +
+ +
+ )} +
+ ); +} + +function ThinkingBlock({ message }: { message: ChatMessage }) { + return ( +
+
+ ); +} + +function SystemLine({ message }: { message: ChatMessage }) { + return ( +

{message.content}

+ ); +} + +function ErrorCard({ message }: { message: ChatMessage }) { + return ( +
+
+ ); +} + +function EmptyState() { + return ( +
+
+ ); +} + +function MessageRow({ + message, + isLast, + status, +}: { + message: ChatMessage; + isLast: boolean; + status: AgentChatStatus; +}) { + switch (message.role) { + case 'user': + return ; + case 'assistant': + return ; + case 'tool_use': + return ; + case 'tool_result': + return ; + case 'thinking': + return ; + case 'system': + return ; + case 'error': + return ; + default: + return null; + } +} + +// ── Main component ──────────────────────────────────────────────────────── + +export function AgentChatPanel({ sessionId, className }: AgentChatPanelProps) { + const { state, sendMessage, interrupt } = useAgentChat(sessionId); + const { messages, status, costUsd } = state; + + const [value, setValue] = useState(''); + const [autoScroll, setAutoScroll] = useState(true); + + const containerRef = useRef(null); + const bottomRef = useRef(null); + const textareaRef = useRef(null); + + const isBusy = status === 'thinking' || status === 'streaming'; + + // Track last message so streaming content updates (same array length) also trigger scroll + const lastMessage = messages[messages.length - 1]; + + // Auto-scroll on new messages and on in-place streaming updates to the last message + useEffect(() => { + if (autoScroll && bottomRef.current) { + bottomRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [autoScroll, lastMessage?.id, lastMessage?.content]); + + const handleScroll = useCallback(() => { + const container = containerRef.current; + if (!container) return; + const { scrollTop, scrollHeight, clientHeight } = container; + setAutoScroll(scrollHeight - scrollTop - clientHeight < 40); + }, []); + + const handleSend = useCallback(() => { + const trimmed = value.trim(); + if (!trimmed || isBusy) return; + sendMessage(trimmed); + setValue(''); + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + }, [value, isBusy, sendMessage]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend] + ); + + const handleInput = (e: React.ChangeEvent) => { + setValue(e.target.value); + const el = e.target; + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight}px`; + }; + + return ( +
+ {/* Header */} +
+
+ + + claude-sonnet-4-6 + +
+ + ${costUsd.toFixed(4)} + +
+ + {/* Message list */} +
+ {messages.length === 0 ? ( + + ) : ( + messages.map((msg, i) => ( + + )) + )} +
+
+ + {/* Input bar */} +
+
+ {isBusy && ( + + )} +