feat: initial implementation of Light Chat app with React, Capacitor, and comprehensive tests

This commit is contained in:
2026-02-26 01:13:07 +01:00
commit fcc8b78887
24 changed files with 7540 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
import { useState, useRef, useEffect } from 'react'
import { sendMessage } from '../utils/llmApi.js'
/**
* ChatWindow - Main chat interface
*/
export function ChatWindow({ session, config, onAddMessage }) {
const [input, setInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
const messagesEndRef = useRef(null)
const scrollToBottom = () => {
if (messagesEndRef.current && typeof messagesEndRef.current.scrollIntoView === 'function') {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}
useEffect(() => {
scrollToBottom()
}, [session?.messages])
const handleSubmit = async (e) => {
e.preventDefault()
if (!input.trim() || isLoading) return
if (!config.endpoint) {
setError('Please configure API endpoint in the drawer')
return
}
const userMessage = input.trim()
setInput('')
setError(null)
// Add user message
onAddMessage('user', userMessage)
setIsLoading(true)
try {
// Prepare messages for API
const apiMessages = session?.messages || []
const response = await sendMessage(
config.endpoint,
userMessage,
apiMessages,
config.systemPrompt
)
// Add assistant response
onAddMessage('assistant', response)
} catch (err) {
setError(err.message || 'Failed to get response')
} finally {
setIsLoading(false)
}
}
const exportConversation = () => {
if (!session || session.messages.length === 0) {
alert('No messages to export')
return
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
let content = `# Conversation Export\n`
content += `Date: ${new Date().toLocaleString()}\n`
content += `Endpoint: ${config.endpoint}\n`
if (config.systemPrompt) {
content += `System Prompt: ${config.systemPrompt}\n`
}
content += '\n---\n\n'
session.messages.forEach(msg => {
const role = msg.role === 'user' ? 'User' : 'Assistant'
content += `**[${role}]** ${new Date().toLocaleTimeString()}\n${msg.content}\n\n`
})
// Create and download file
const blob = new Blob([content], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `conversation-${timestamp}.md`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
return (
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
backgroundColor: 'var(--bg-primary)'
}}>
{/* Header */}
<header style={{
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
borderBottom: '1px solid var(--border)',
backgroundColor: 'var(--bg-secondary)'
}}>
<div style={{ flex: 1 }}>
<h1 style={{ fontSize: '16px', fontWeight: 600 }}>{session?.name || 'Light Chat'}</h1>
</div>
<button
onClick={exportConversation}
disabled={!session || session.messages.length === 0}
title="Export conversation"
style={{
padding: '6px 12px',
fontSize: '12px',
marginRight: '8px'
}}
>
Export
</button>
</header>
{/* Messages */}
<div style={{
flex: 1,
overflowY: 'auto',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '12px'
}}>
{!session ? (
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-secondary)'
}}>
Select or create a session to start chatting
</div>
) : session.messages.length === 0 ? (
<div style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-secondary)'
}}>
Start the conversation by sending a message
</div>
) : (
session.messages.map((msg, index) => (
<div
key={index}
style={{
display: 'flex',
flexDirection: msg.role === 'user' ? 'row' : 'row',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start'
}}
>
<div
style={{
maxWidth: '80%',
padding: '8px 12px',
borderRadius: '8px',
backgroundColor: msg.role === 'user'
? 'var(--accent)'
: 'var(--bg-secondary)',
border: '1px solid',
borderColor: msg.role === 'user' ? 'var(--accent)' : 'var(--border)'
}}
>
<div style={{
fontSize: '12px',
color: msg.role === 'user' ? 'white' : 'var(--text-secondary)',
marginBottom: '4px'
}}>
{msg.role === 'user' ? 'You' : 'Assistant'}
</div>
<div style={{ whiteSpace: 'pre-wrap', wordWrap: 'break-word' }}>
{msg.content}
</div>
</div>
</div>
))
)}
{isLoading && (
<div style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>
Thinking...
</div>
)}
{error && (
<div style={{ color: 'var(--error)', fontSize: '14px' }}>
Error: {error}
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Form */}
<form
onSubmit={handleSubmit}
style={{
padding: '16px',
borderTop: '1px solid var(--border)',
backgroundColor: 'var(--bg-secondary)',
display: 'flex',
gap: '8px'
}}
>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={config.endpoint ? "Type your message..." : "Configure API endpoint first"}
disabled={isLoading || !session}
style={{ flex: 1 }}
/>
<button type="submit" disabled={isLoading || !input.trim() || !session}>
Send
</button>
</form>
</div>
)
}
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ChatWindow } from '../components/ChatWindow.jsx'
import { sendMessage } from '../utils/llmApi.js'
// Mock the llmApi module
vi.mock('../utils/llmApi.js', () => ({
sendMessage: vi.fn(),
testConnection: vi.fn()
}))
describe('ChatWindow', () => {
const defaultProps = {
session: {
id: '1',
name: 'Test Session',
messages: []
},
config: {
endpoint: 'https://api.test.com',
systemPrompt: 'You are helpful'
},
onAddMessage: vi.fn()
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should render with empty state', () => {
render(<ChatWindow {...defaultProps} />)
expect(screen.getByText(/Test Session/)).toBeInTheDocument()
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('placeholder', 'Type your message...')
expect(screen.getByRole('button', { name: /Export/i })).toBeInTheDocument()
})
it('should render messages', () => {
const sessionWithMessages = {
...defaultProps.session,
messages: [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there!' }
]
}
render(<ChatWindow {...defaultProps} session={sessionWithMessages} />)
expect(screen.getByText('Hello')).toBeInTheDocument()
expect(screen.getByText('Hi there!')).toBeInTheDocument()
})
it('should handle form submission', async () => {
const user = userEvent.setup()
sendMessage.mockResolvedValueOnce('Response')
render(<ChatWindow {...defaultProps} />)
const input = screen.getByRole('textbox')
await user.type(input, 'Hello')
await user.click(screen.getByRole('button', { name: /Send/i }))
expect(defaultProps.onAddMessage).toHaveBeenCalledWith('user', 'Hello')
await waitFor(() => {
expect(sendMessage).toHaveBeenCalled()
})
})
it('should show error when no endpoint configured', async () => {
const user = userEvent.setup()
const noEndpointProps = {
...defaultProps,
config: { endpoint: '', systemPrompt: '' }
}
render(<ChatWindow {...noEndpointProps} />)
const input = screen.getByRole('textbox')
await user.type(input, 'Hello')
await user.click(screen.getByRole('button', { name: /Send/i }))
expect(screen.getByText(/Please configure API endpoint/)).toBeInTheDocument()
})
it('should disable input when no session', () => {
render(<ChatWindow {...defaultProps} session={null} />)
const input = screen.getByRole('textbox')
expect(input).toBeDisabled()
expect(input).toHaveAttribute('placeholder', 'Type your message...')
})
})
+172
View File
@@ -0,0 +1,172 @@
import { useState } from 'react'
/**
* SessionDrawer - Sidebar showing sessions list and config
*/
export function SessionDrawer({
isOpen,
onClose,
sessions,
currentSessionId,
onSelectSession,
onCreateSession,
onDeleteSession,
config,
onUpdateConfig
}) {
const [localConfig, setLocalConfig] = useState(config)
const handleConfigChange = (field, value) => {
setLocalConfig(prev => ({ ...prev, [field]: value }))
}
const saveConfig = () => {
onUpdateConfig(localConfig)
}
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
onClose()
}
}
return (
<>
{/* Overlay */}
{isOpen && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
zIndex: 999
}}
onClick={onClose}
/>
)}
{/* Drawer */}
<div
style={{
position: 'fixed',
top: 0,
left: 0,
bottom: 0,
width: '300px',
backgroundColor: 'var(--bg-secondary)',
borderRight: '1px solid var(--border)',
transform: isOpen ? 'translateX(0)' : 'translateX(-100%)',
transition: 'transform 0.3s ease',
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
padding: '16px'
}}
onKeyDown={handleKeyDown}
tabIndex={isOpen ? 0 : -1}
>
<div style={{ marginBottom: '20px' }}>
<h2 style={{ fontSize: '18px', marginBottom: '16px' }}>Sessions</h2>
<button
onClick={() => {
onCreateSession()
onClose()
}}
style={{ width: '100%', marginBottom: '12px' }}
>
+ New Session
</button>
</div>
{/* Config Section */}
<div style={{ marginBottom: '20px' }}>
<h3 style={{ fontSize: '14px', marginBottom: '8px', color: 'var(--text-secondary)' }}>
API Configuration
</h3>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px' }}>
Endpoint
</label>
<input
type="text"
value={localConfig.endpoint}
onChange={(e) => handleConfigChange('endpoint', e.target.value)}
placeholder="https://api.openai.com/v1/chat/completions"
/>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px' }}>
System Prompt
</label>
<textarea
value={localConfig.systemPrompt}
onChange={(e) => handleConfigChange('systemPrompt', e.target.value)}
placeholder="You are a helpful assistant..."
rows={3}
style={{ resize: 'vertical' }}
/>
</div>
<button onClick={saveConfig} style={{ width: '100%' }}>
Save Config
</button>
</div>
{/* Sessions List */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{sessions.length === 0 ? (
<p style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>
No sessions yet. Create one to start chatting.
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{sessions.map(session => (
<li
key={session.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
marginBottom: '4px',
borderRadius: '4px',
backgroundColor: currentSessionId === session.id
? 'var(--bg-tertiary)'
: 'transparent',
cursor: 'pointer'
}}
onClick={() => {
onSelectSession(session.id)
onClose()
}}
>
<span style={{ fontSize: '14px', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{session.name}
</span>
<button
onClick={(e) => {
e.stopPropagation()
if (window.confirm('Delete this session?')) {
onDeleteSession(session.id)
}
}}
style={{
padding: '4px 8px',
fontSize: '12px',
backgroundColor: 'var(--error)',
marginLeft: '8px'
}}
>
Delete
</button>
</li>
))}
</ul>
)}
</div>
</div>
</>
)
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { SessionDrawer } from '../components/SessionDrawer.jsx'
describe('SessionDrawer', () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
sessions: [],
currentSessionId: null,
onSelectSession: vi.fn(),
onCreateSession: vi.fn(),
onDeleteSession: vi.fn(),
config: { endpoint: '', systemPrompt: '' },
onUpdateConfig: vi.fn()
}
afterEach(() => {
vi.restoreAllMocks()
})
it('should render when open', () => {
render(<SessionDrawer {...defaultProps} />)
expect(screen.getByText(/Sessions/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /New Session/i })).toBeInTheDocument()
})
it('should call onClose when overlay clicked', () => {
render(<SessionDrawer {...defaultProps} />)
const overlay = document.querySelector('[style*="position: fixed"]')
fireEvent.click(overlay)
expect(defaultProps.onClose).toHaveBeenCalled()
})
it('should display sessions list', () => {
const sessions = [
{ id: '1', name: 'Session 1', messages: [] },
{ id: '2', name: 'Session 2', messages: [] }
]
render(<SessionDrawer {...defaultProps} sessions={sessions} currentSessionId="1" />)
expect(screen.getByText('Session 1')).toBeInTheDocument()
expect(screen.getByText('Session 2')).toBeInTheDocument()
})
it('should call onSelectSession when session clicked', () => {
const sessions = [{ id: '1', name: 'Session 1', messages: [] }]
render(<SessionDrawer {...defaultProps} sessions={sessions} />)
fireEvent.click(screen.getByText('Session 1'))
expect(defaultProps.onSelectSession).toHaveBeenCalledWith('1')
expect(defaultProps.onClose).toHaveBeenCalled()
})
it('should call onCreateSession when new session button clicked', () => {
render(<SessionDrawer {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /New Session/i }))
expect(defaultProps.onCreateSession).toHaveBeenCalled()
expect(defaultProps.onClose).toHaveBeenCalled()
})
it('should call onUpdateConfig when save config clicked', () => {
render(<SessionDrawer {...defaultProps} />)
const endpointInput = screen.getByPlaceholderText(/openai.com/i)
fireEvent.change(endpointInput, { target: { value: 'https://new.com' } })
fireEvent.click(screen.getByRole('button', { name: /Save Config/i }))
expect(defaultProps.onUpdateConfig).toHaveBeenCalledWith({ endpoint: 'https://new.com', systemPrompt: '' })
})
})