diff --git a/frontend/src/components/ChatPanel.tsx b/frontend/src/components/ChatPanel.tsx new file mode 100644 index 0000000..53d7a98 --- /dev/null +++ b/frontend/src/components/ChatPanel.tsx @@ -0,0 +1,91 @@ +import { useState, useRef, useEffect } from 'react' +import type { KeyboardEvent } from 'react' +import ReactMarkdown from 'react-markdown' +import { IconSend } from '@tabler/icons-react' +import { ToolCallCard } from './ToolCallCard' +import type { ChatMessage } from '../hooks/useChat' + +interface ChatPanelProps { + messages: ChatMessage[] + isStreaming: boolean + onSend: (content: string) => void + sessionId: string | null +} + +export function ChatPanel({ messages, isStreaming, onSend, sessionId }: ChatPanelProps) { + const [input, setInput] = useState('') + const messagesEndRef = useRef(null) + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + function handleSend() { + if (input.trim() && !isStreaming) { + onSend(input.trim()) + setInput('') + } + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + if (sessionId === null) { + return ( +
+
+ + Select or create a session to start researching + +
+
+ ) + } + + return ( +
+
+ {messages.map(message => ( +
+ {message.role === 'assistant' ? ( +
+ {message.content} + {message.toolCalls?.map(tc => ( + + ))} +
+ ) : ( +
{message.content}
+ )} +
+ ))} + {isStreaming && messages.length > 0 &&
Researching...
} +
+
+
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + disabled={isStreaming} + autoFocus + /> + +
+
+ ) +} diff --git a/frontend/src/components/ToolCallCard.tsx b/frontend/src/components/ToolCallCard.tsx new file mode 100644 index 0000000..46261ee --- /dev/null +++ b/frontend/src/components/ToolCallCard.tsx @@ -0,0 +1,52 @@ +import { IconTerminal, IconCheck, IconLoader } from '@tabler/icons-react' +import type { ToolCall } from '../lib/types' + +interface ToolCallCardProps { + toolCall: ToolCall +} + +export function ToolCallCard({ toolCall }: ToolCallCardProps) { + let argsSummary: string | undefined + + // Spec: Try JSON.parse(toolCall.args); if parsed.command exists use as argsSummary, + // otherwise fall back to raw args + try { + // args may be stored as a JSON string via _raw or directly as an object + const parsed = JSON.parse(toolCall.args as unknown as string) + if (parsed.command) { + argsSummary = String(parsed.command) + } else { + argsSummary = toolCall.args as unknown as string + } + } catch { + // args is a Record object — access fields directly + if (toolCall.args.command !== undefined) { + argsSummary = String(toolCall.args.command) + } else { + const raw = JSON.stringify(toolCall.args) + if (raw !== '{}') { + argsSummary = raw + } + } + } + + const statusText = toolCall.status + + return ( +
+ + + {toolCall.name} + + + {toolCall.status === 'running' ? ( + + ) : ( + + )}{' '} + {statusText} + + {argsSummary &&
{argsSummary}
} +
+ ) +} diff --git a/frontend/src/test/ChatPanel.test.tsx b/frontend/src/test/ChatPanel.test.tsx new file mode 100644 index 0000000..4b434a1 --- /dev/null +++ b/frontend/src/test/ChatPanel.test.tsx @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeAll } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ChatPanel } from '../components/ChatPanel' +import type { ChatMessage } from '../hooks/useChat' + +// scrollIntoView is not implemented in jsdom +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn() +}) + +// Mock react-markdown to avoid ESM issues in test environment +vi.mock('react-markdown', () => ({ + default: ({ children }: { children: string }) =>
{children}
, +})) + +describe('ChatPanel', () => { + const noop = () => undefined + + describe('empty state — no session', () => { + it('renders chat-panel class when sessionId is null', () => { + const { container } = render( + + ) + expect(container.querySelector('.chat-panel')).toBeTruthy() + }) + + it('shows placeholder text when sessionId is null', () => { + render( + + ) + expect( + screen.getByText('Select or create a session to start researching') + ).toBeTruthy() + }) + + it('does not render chat-messages when sessionId is null', () => { + const { container } = render( + + ) + expect(container.querySelector('.chat-messages')).toBeNull() + }) + }) + + describe('with active session', () => { + const sessionId = 'session-123' + + it('renders chat-panel and chat-messages when sessionId provided', () => { + const { container } = render( + + ) + expect(container.querySelector('.chat-panel')).toBeTruthy() + expect(container.querySelector('.chat-messages')).toBeTruthy() + }) + + it('renders user message content', () => { + const messages: ChatMessage[] = [ + { id: '1', role: 'user', content: 'Hello world' }, + ] + render( + + ) + expect(screen.getByText('Hello world')).toBeTruthy() + }) + + it('renders assistant message via ReactMarkdown', () => { + const messages: ChatMessage[] = [ + { id: '1', role: 'assistant', content: 'This is a response' }, + ] + render( + + ) + expect(screen.getByText('This is a response')).toBeTruthy() + }) + + it('renders tool calls via ToolCallCard for assistant messages', () => { + const messages: ChatMessage[] = [ + { + id: '1', + role: 'assistant', + content: 'Running a tool', + toolCalls: [ + { id: 'tc-1', name: 'bash', args: {}, status: 'complete' }, + ], + }, + ] + const { container } = render( + + ) + expect(container.querySelector('.tool-call-card')).toBeTruthy() + }) + + it('shows Researching... when isStreaming and messages exist', () => { + const messages: ChatMessage[] = [ + { id: '1', role: 'user', content: 'Question' }, + ] + render( + + ) + expect(screen.getByText('Researching...')).toBeTruthy() + }) + + it('does not show Researching... when not streaming', () => { + const messages: ChatMessage[] = [ + { id: '1', role: 'user', content: 'Question' }, + ] + render( + + ) + expect(screen.queryByText('Researching...')).toBeNull() + }) + + it('does not show Researching... when streaming but no messages', () => { + render( + + ) + expect(screen.queryByText('Researching...')).toBeNull() + }) + + it('renders chat-input-area', () => { + const { container } = render( + + ) + expect(container.querySelector('.chat-input-area')).toBeTruthy() + }) + + it('renders input with placeholder text', () => { + render( + + ) + expect(screen.getByPlaceholderText('Ask a research question...')).toBeTruthy() + }) + + it('send button is disabled when input is empty', () => { + render( + + ) + const btn = screen.getByRole('button') + expect((btn as HTMLButtonElement).disabled).toBe(true) + }) + + it('send button is disabled when streaming even with input', async () => { + render( + + ) + // Input is also disabled while streaming, so button remains disabled + const btn = screen.getByRole('button') + expect((btn as HTMLButtonElement).disabled).toBe(true) + }) + + it('input is disabled while streaming', () => { + render( + + ) + const input = screen.getByPlaceholderText('Ask a research question...') + expect((input as HTMLInputElement).disabled).toBe(true) + }) + + it('calls onSend with trimmed input when send button clicked', async () => { + const onSend = vi.fn() + render( + + ) + const input = screen.getByPlaceholderText('Ask a research question...') + await userEvent.type(input, 'My question') + await userEvent.click(screen.getByRole('button')) + expect(onSend).toHaveBeenCalledWith('My question') + }) + + it('clears input after sending', async () => { + render( + + ) + const input = screen.getByPlaceholderText('Ask a research question...') + await userEvent.type(input, 'My question') + await userEvent.click(screen.getByRole('button')) + expect((input as HTMLInputElement).value).toBe('') + }) + + it('sends on Enter without Shift', async () => { + const onSend = vi.fn() + render( + + ) + const input = screen.getByPlaceholderText('Ask a research question...') + await userEvent.type(input, 'My question{Enter}') + expect(onSend).toHaveBeenCalledWith('My question') + }) + + it('does not send on Shift+Enter', async () => { + const onSend = vi.fn() + render( + + ) + const input = screen.getByPlaceholderText('Ask a research question...') + await userEvent.type(input, 'My question') + await userEvent.keyboard('{Shift>}{Enter}{/Shift}') + expect(onSend).not.toHaveBeenCalled() + }) + + it('does not call onSend when input is only whitespace', async () => { + const onSend = vi.fn() + render( + + ) + const input = screen.getByPlaceholderText('Ask a research question...') + await userEvent.type(input, ' {Enter}') + expect(onSend).not.toHaveBeenCalled() + }) + }) +}) diff --git a/frontend/src/test/ToolCallCard.test.tsx b/frontend/src/test/ToolCallCard.test.tsx new file mode 100644 index 0000000..809a459 --- /dev/null +++ b/frontend/src/test/ToolCallCard.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { ToolCallCard } from '../components/ToolCallCard' +import type { ToolCall } from '../lib/types' + +describe('ToolCallCard', () => { + const baseToolCall: ToolCall = { + id: 'tc-1', + name: 'bash', + args: {}, + status: 'complete', + } + + it('renders the tool-call-card container', () => { + const { container } = render() + expect(container.querySelector('.tool-call-card')).toBeTruthy() + }) + + it('renders the tool name in tool-name span', () => { + const { container } = render() + expect(container.querySelector('.tool-name')?.textContent).toContain('bash') + }) + + it('renders status span with tool-status and running class when status is running', () => { + const { container } = render( + + ) + expect(container.querySelector('.tool-status.running')).toBeTruthy() + }) + + it('renders status span with tool-status and complete class when status is complete', () => { + const { container } = render( + + ) + expect(container.querySelector('.tool-status.complete')).toBeTruthy() + }) + + it('shows status text in the status span', () => { + render() + expect(screen.getByText(/running/)).toBeTruthy() + }) + + it('shows command as argsSummary when args has command field', () => { + const toolCall: ToolCall = { + ...baseToolCall, + args: { command: 'ls -la' }, + } + const { container } = render() + expect(container.querySelector('.tool-args')).toBeTruthy() + expect(screen.getByText('ls -la')).toBeTruthy() + }) + + it('does not render tool-args div when args is empty', () => { + const { container } = render() + expect(container.querySelector('.tool-args')).toBeNull() + }) + + it('falls back to JSON-stringified args when no command field', () => { + const toolCall: ToolCall = { + ...baseToolCall, + args: { path: '/tmp/file.txt' }, + } + const { container } = render() + const argsDiv = container.querySelector('.tool-args') + expect(argsDiv).toBeTruthy() + expect(argsDiv?.textContent).toContain('/tmp/file.txt') + }) +})