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