feat: US-008 - Chat widget — panel UI with message display

This commit is contained in:
2026-02-15 18:18:51 +00:00
parent 7ee1a2d9de
commit 273c143d5e
3 changed files with 306 additions and 5 deletions
+275 -4
View File
@@ -1,9 +1,14 @@
import { useState } from 'react'
import { useState, useRef, useEffect, type KeyboardEvent } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { MessageCircle, X } from 'lucide-react'
import { MessageCircle, X, Send } from 'lucide-react'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
const buttonVariants = {
hidden: prefersReducedMotion
? { opacity: 1, y: 0 }
@@ -17,14 +22,280 @@ const buttonVariants = {
},
}
const panelVariants = {
hidden: prefersReducedMotion
? { opacity: 1, scale: 1 }
: { opacity: 0, scale: 0.95 },
visible: {
opacity: 1,
scale: 1,
transition: prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeOut' },
},
exit: prefersReducedMotion
? { opacity: 1, scale: 1 }
: { opacity: 0, scale: 0.95, transition: { duration: 0.15, ease: 'easeIn' } },
}
const PLACEHOLDER_RESPONSE = 'AI chat coming soon — this is a preview of the chat interface.'
export function ChatWidget() {
const [isOpen, setIsOpen] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [inputValue, setInputValue] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
// Auto-scroll to latest message
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Focus input when panel opens
useEffect(() => {
if (isOpen) {
setTimeout(() => inputRef.current?.focus(), 200)
}
}, [isOpen])
const handleSubmit = () => {
const trimmed = inputValue.trim()
if (!trimmed) return
setMessages((prev) => [
...prev,
{ role: 'user', content: trimmed },
{ role: 'assistant', content: PLACEHOLDER_RESPONSE },
])
setInputValue('')
}
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}
return (
<>
{/* Chat panel placeholder — wired in US-008 */}
{/* Chat panel */}
<AnimatePresence>
{isOpen && null}
{isOpen && (
<motion.div
key="chat-panel"
initial="hidden"
animate="visible"
exit="exit"
variants={panelVariants}
role="dialog"
aria-label="Chat with AI about Andy"
className="fixed z-[90] font-ui
bottom-0 left-0 right-0 rounded-t-xl
sm:bottom-[88px] sm:right-6 sm:left-auto sm:rounded-xl"
style={{
width: undefined,
background: 'var(--surface)',
border: '1px solid var(--border-light)',
boxShadow: 'var(--shadow-lg)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
transformOrigin: 'bottom right',
}}
>
{/* Use inline style for sm-width via CSS class override */}
<style>{`
@media (min-width: 640px) {
[data-chat-panel] { width: 380px; max-height: 480px; }
}
@media (max-width: 639px) {
[data-chat-panel] { height: 85vh; max-height: 85vh; }
}
`}</style>
<div
data-chat-panel
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
maxHeight: '480px',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '14px 16px',
borderBottom: '1px solid var(--border-light)',
flexShrink: 0,
}}
>
<span
style={{
fontSize: '14px',
fontWeight: 600,
color: 'var(--text-primary)',
}}
>
Ask about Andy
</span>
<button
onClick={() => setIsOpen(false)}
aria-label="Close chat"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
borderRadius: '6px',
border: 'none',
background: 'transparent',
color: 'var(--text-secondary)',
cursor: 'pointer',
transition: 'background-color 150ms ease-out',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent-light)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
}}
>
<X size={16} strokeWidth={2} />
</button>
</div>
{/* Messages area */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
className="pmr-scrollbar"
>
{messages.length === 0 && (
<div
style={{
textAlign: 'center',
padding: '32px 16px',
color: 'var(--text-tertiary)',
fontSize: '13px',
lineHeight: 1.5,
}}
>
Ask me anything about Andy's experience, skills, or projects.
</div>
)}
{messages.map((msg, i) => (
<div
key={i}
style={{
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
}}
>
<div
style={{
maxWidth: '85%',
padding: '10px 14px',
borderRadius: msg.role === 'user'
? '12px 12px 4px 12px'
: '12px 12px 12px 4px',
fontSize: '13px',
lineHeight: 1.5,
background: msg.role === 'user'
? 'var(--accent-light)'
: 'var(--bg-dashboard)',
color: 'var(--text-primary)',
border: msg.role === 'user'
? '1px solid var(--accent-border)'
: '1px solid var(--border-light)',
}}
>
{msg.content}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div
style={{
padding: '12px 16px',
borderTop: '1px solid var(--border-light)',
display: 'flex',
alignItems: 'flex-end',
gap: '8px',
flexShrink: 0,
}}
>
<textarea
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask me anything..."
rows={1}
style={{
flex: 1,
resize: 'none',
border: '1px solid var(--border-light)',
borderRadius: '8px',
padding: '10px 12px',
fontSize: '13px',
lineHeight: 1.5,
color: 'var(--text-primary)',
background: 'var(--surface)',
outline: 'none',
fontFamily: 'inherit',
maxHeight: '80px',
overflowY: 'auto',
transition: 'border-color 150ms ease-out',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--accent)'
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--border-light)'
}}
/>
<button
onClick={handleSubmit}
disabled={!inputValue.trim()}
aria-label="Send message"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '36px',
height: '36px',
borderRadius: '8px',
border: 'none',
background: inputValue.trim() ? 'var(--accent)' : 'var(--border-light)',
color: inputValue.trim() ? '#FFFFFF' : 'var(--text-tertiary)',
cursor: inputValue.trim() ? 'pointer' : 'default',
flexShrink: 0,
transition: 'background-color 150ms ease-out, color 150ms ease-out',
}}
>
<Send size={16} strokeWidth={2} />
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Floating chat button */}