feat: chat panel with SSE streaming and tool call cards

This commit is contained in:
Ken
2026-05-26 19:28:12 +00:00
parent c24fd11f03
commit 3b01753336
4 changed files with 422 additions and 0 deletions
+91
View File
@@ -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>
)
}
+52
View File
@@ -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>
)
}
+211
View File
@@ -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()
})
})
})
+68
View File
@@ -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')
})
})