feat: chat panel with SSE streaming and tool call cards
This commit is contained in:
@@ -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<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
function handleSend() {
|
||||
if (input.trim() && !isStreaming) {
|
||||
onSend(input.trim())
|
||||
setInput('')
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId === null) {
|
||||
return (
|
||||
<div className='chat-panel'>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#6a6a8a', fontSize: '1.1rem' }}>
|
||||
Select or create a session to start researching
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='chat-panel'>
|
||||
<div className='chat-messages'>
|
||||
{messages.map(message => (
|
||||
<div key={message.id}>
|
||||
{message.role === 'assistant' ? (
|
||||
<div>
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
{message.toolCalls?.map(tc => (
|
||||
<ToolCallCard key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div>{message.content}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isStreaming && messages.length > 0 && <div>Researching...</div>}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<div className='chat-input-area'>
|
||||
<input
|
||||
placeholder='Ask a research question...'
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isStreaming}
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={handleSend} disabled={isStreaming || !input.trim()}>
|
||||
<IconSend size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, unknown> 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 (
|
||||
<div className='tool-call-card'>
|
||||
<span className='tool-name'>
|
||||
<IconTerminal size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
{toolCall.name}
|
||||
</span>
|
||||
<span className={`tool-status ${toolCall.status}`}>
|
||||
{toolCall.status === 'running' ? (
|
||||
<IconLoader size={12} />
|
||||
) : (
|
||||
<IconCheck size={12} />
|
||||
)}{' '}
|
||||
{statusText}
|
||||
</span>
|
||||
{argsSummary && <div className='tool-args'>{argsSummary}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
describe('ChatPanel', () => {
|
||||
const noop = () => undefined
|
||||
|
||||
describe('empty state — no session', () => {
|
||||
it('renders chat-panel class when sessionId is null', () => {
|
||||
const { container } = render(
|
||||
<ChatPanel messages={[]} isStreaming={false} onSend={noop} sessionId={null} />
|
||||
)
|
||||
expect(container.querySelector('.chat-panel')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows placeholder text when sessionId is null', () => {
|
||||
render(
|
||||
<ChatPanel messages={[]} isStreaming={false} onSend={noop} sessionId={null} />
|
||||
)
|
||||
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(
|
||||
<ChatPanel messages={[]} isStreaming={false} onSend={noop} sessionId={null} />
|
||||
)
|
||||
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(
|
||||
<ChatPanel messages={[]} isStreaming={false} onSend={noop} sessionId={sessionId} />
|
||||
)
|
||||
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(
|
||||
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
|
||||
)
|
||||
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(
|
||||
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
|
||||
)
|
||||
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(
|
||||
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
|
||||
)
|
||||
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(
|
||||
<ChatPanel messages={messages} isStreaming={true} onSend={noop} sessionId={sessionId} />
|
||||
)
|
||||
expect(screen.getByText('Researching...')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not show Researching... when not streaming', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ id: '1', role: 'user', content: 'Question' },
|
||||
]
|
||||
render(
|
||||
<ChatPanel messages={messages} isStreaming={false} onSend={noop} sessionId={sessionId} />
|
||||
)
|
||||
expect(screen.queryByText('Researching...')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show Researching... when streaming but no messages', () => {
|
||||
render(
|
||||
<ChatPanel messages={[]} isStreaming={true} onSend={noop} sessionId={sessionId} />
|
||||
)
|
||||
expect(screen.queryByText('Researching...')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders chat-input-area', () => {
|
||||
const { container } = render(
|
||||
<ChatPanel messages={[]} isStreaming={false} onSend={noop} sessionId={sessionId} />
|
||||
)
|
||||
expect(container.querySelector('.chat-input-area')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders input with placeholder text', () => {
|
||||
render(
|
||||
<ChatPanel messages={[]} isStreaming={false} onSend={noop} sessionId={sessionId} />
|
||||
)
|
||||
expect(screen.getByPlaceholderText('Ask a research question...')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('send button is disabled when input is empty', () => {
|
||||
render(
|
||||
<ChatPanel messages={[]} isStreaming={false} onSend={noop} sessionId={sessionId} />
|
||||
)
|
||||
const btn = screen.getByRole('button')
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('send button is disabled when streaming even with input', async () => {
|
||||
render(
|
||||
<ChatPanel messages={[]} isStreaming={true} onSend={noop} sessionId={sessionId} />
|
||||
)
|
||||
// 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(
|
||||
<ChatPanel messages={[]} isStreaming={true} onSend={noop} sessionId={sessionId} />
|
||||
)
|
||||
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(
|
||||
<ChatPanel messages={[]} isStreaming={false} onSend={onSend} sessionId={sessionId} />
|
||||
)
|
||||
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(
|
||||
<ChatPanel messages={[]} isStreaming={false} onSend={noop} sessionId={sessionId} />
|
||||
)
|
||||
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(
|
||||
<ChatPanel messages={[]} isStreaming={false} onSend={onSend} sessionId={sessionId} />
|
||||
)
|
||||
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(
|
||||
<ChatPanel messages={[]} isStreaming={false} onSend={onSend} sessionId={sessionId} />
|
||||
)
|
||||
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(
|
||||
<ChatPanel messages={[]} isStreaming={false} onSend={onSend} sessionId={sessionId} />
|
||||
)
|
||||
const input = screen.getByPlaceholderText('Ask a research question...')
|
||||
await userEvent.type(input, ' {Enter}')
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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(<ToolCallCard toolCall={baseToolCall} />)
|
||||
expect(container.querySelector('.tool-call-card')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the tool name in tool-name span', () => {
|
||||
const { container } = render(<ToolCallCard toolCall={baseToolCall} />)
|
||||
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(
|
||||
<ToolCallCard toolCall={{ ...baseToolCall, status: 'running' }} />
|
||||
)
|
||||
expect(container.querySelector('.tool-status.running')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders status span with tool-status and complete class when status is complete', () => {
|
||||
const { container } = render(
|
||||
<ToolCallCard toolCall={{ ...baseToolCall, status: 'complete' }} />
|
||||
)
|
||||
expect(container.querySelector('.tool-status.complete')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows status text in the status span', () => {
|
||||
render(<ToolCallCard toolCall={{ ...baseToolCall, status: 'running' }} />)
|
||||
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(<ToolCallCard toolCall={toolCall} />)
|
||||
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(<ToolCallCard toolCall={baseToolCall} />)
|
||||
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(<ToolCallCard toolCall={toolCall} />)
|
||||
const argsDiv = container.querySelector('.tool-args')
|
||||
expect(argsDiv).toBeTruthy()
|
||||
expect(argsDiv?.textContent).toContain('/tmp/file.txt')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user