feat: add docs chat widget UI components (E3)
- Add useDocsChat hook with message state, streaming, error handling - Add ChatMessages component with auto-scroll and streaming cursor - Add ChatInput component with Enter/Shift+Enter support - Add DocsChatWidget floating container with amber styling - Add AI integration specs (PRD, FO, bouwplan) Implements Epic 3 of AI Documentatie Assistent feature. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
150
components/docs-chat/chat-messages.tsx
Normal file
150
components/docs-chat/chat-messages.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ChatMessage } from './use-docs-chat'
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages: ChatMessage[]
|
||||
isStreaming: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Message list component with auto-scroll and streaming cursor
|
||||
*
|
||||
* UI Specs (uit FO):
|
||||
* - User messages: rechts, bg-amber-100, rounded
|
||||
* - Assistant messages: links, bg-slate-100, rounded
|
||||
* - Streaming: pulserende cursor ▊
|
||||
*/
|
||||
export function ChatMessages({ messages, isStreaming }: ChatMessagesProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-4 space-y-3"
|
||||
>
|
||||
{messages.map((message) => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={isStreaming && message.role === 'assistant' && message === messages[messages.length - 1]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ChatMessage
|
||||
isStreaming: boolean
|
||||
}
|
||||
|
||||
function MessageBubble({ message, isStreaming }: MessageBubbleProps) {
|
||||
const isUser = message.role === 'user'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex',
|
||||
isUser ? 'justify-end' : 'justify-start'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[85%] rounded-2xl px-4 py-2 text-sm',
|
||||
isUser
|
||||
? 'bg-amber-100 text-amber-900 rounded-br-md'
|
||||
: 'bg-slate-100 text-slate-900 rounded-bl-md'
|
||||
)}
|
||||
>
|
||||
<MessageContent content={message.content} isStreaming={isStreaming} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MessageContentProps {
|
||||
content: string
|
||||
isStreaming: boolean
|
||||
}
|
||||
|
||||
function MessageContent({ content, isStreaming }: MessageContentProps) {
|
||||
if (!content && isStreaming) {
|
||||
return <StreamingCursor />
|
||||
}
|
||||
|
||||
// Split content into paragraphs and render with proper spacing
|
||||
const paragraphs = content.split('\n\n')
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{paragraphs.map((paragraph, idx) => (
|
||||
<p key={idx} className="whitespace-pre-wrap leading-relaxed">
|
||||
{renderInlineFormatting(paragraph)}
|
||||
{isStreaming && idx === paragraphs.length - 1 && <StreamingCursor />}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render basic inline formatting (bold, bullet points)
|
||||
*/
|
||||
function renderInlineFormatting(text: string) {
|
||||
// Handle bullet points
|
||||
const lines = text.split('\n')
|
||||
|
||||
return lines.map((line, idx) => {
|
||||
const isBullet = line.startsWith('• ') || line.startsWith('- ')
|
||||
|
||||
if (isBullet) {
|
||||
return (
|
||||
<span key={idx} className="block pl-2">
|
||||
{line}
|
||||
{idx < lines.length - 1 && '\n'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle **bold** text
|
||||
const parts = line.split(/(\*\*[^*]+\*\*)/)
|
||||
const formatted = parts.map((part, partIdx) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return (
|
||||
<strong key={partIdx} className="font-semibold">
|
||||
{part.slice(2, -2)}
|
||||
</strong>
|
||||
)
|
||||
}
|
||||
return part
|
||||
})
|
||||
|
||||
return (
|
||||
<span key={idx}>
|
||||
{formatted}
|
||||
{idx < lines.length - 1 && '\n'}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulsating streaming cursor
|
||||
*/
|
||||
function StreamingCursor() {
|
||||
return (
|
||||
<span className="inline-block w-2 h-4 ml-0.5 bg-amber-500 animate-pulse rounded-sm" />
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user