feat: US-008 - Chat widget — panel UI with message display
This commit is contained in:
+1
-1
@@ -152,7 +152,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 8,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Use the design system tokens: var(--surface) for panel bg, var(--border-light) for borders, var(--text-primary) for text, var(--accent) for user bubble bg at 10% opacity, font-ui for body text, font-geist for timestamps. The placeholder assistant response can be a static string like 'AI chat coming soon — this is a preview of the chat interface.' This lets us verify the full UI before wiring up Gemini."
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
- `loadEmbeddings()` and `paletteMap` (Map<id, PaletteItem>) are precomputed via `useMemo` — no re-computation on each search
|
||||
- ChatWidget is mounted in DashboardLayout alongside CommandPalette and DetailPanel — z-index 90 (below command palette z-1000)
|
||||
- `prefersReducedMotion` pattern: read `window.matchMedia` at module level, use in framer-motion variants to skip animation
|
||||
- ChatWidget stores messages as `Array<{ role: 'user' | 'assistant', content: string }>` — same shape as LLM message format, ready for Gemini integration
|
||||
- ChatWidget `isOpen` state controls both panel visibility and button icon (MessageCircle ↔ X) — panel rendering handled by AnimatePresence
|
||||
|
||||
---
|
||||
|
||||
@@ -135,3 +137,31 @@
|
||||
- The `isOpen` state lives in ChatWidget — US-008 will add the panel UI inside the same component
|
||||
- Hover effects use `onMouseEnter/Leave` with direct style mutation (same pattern as other dashboard components)
|
||||
---
|
||||
|
||||
## 2026-02-15 - US-008
|
||||
- Built chat panel UI inside `ChatWidget.tsx` with header, message area, and input
|
||||
- Panel opens above the floating button with scale+opacity entrance/exit animation via framer-motion `AnimatePresence`
|
||||
- Messages stored as `Array<{ role: 'user' | 'assistant', content: string }>` in component state
|
||||
- User messages right-aligned in teal-tinted bubbles (`var(--accent-light)` bg, `var(--accent-border)` border)
|
||||
- Assistant messages left-aligned in light gray bubbles (`var(--bg-dashboard)` bg, `var(--border-light)` border)
|
||||
- Message corner radii differ: user bubbles have small bottom-right radius, assistant bubbles small bottom-left (conversational feel)
|
||||
- Input area: textarea with Enter to submit, Shift+Enter for newline. Send button enabled/disabled based on input content
|
||||
- Empty state shows placeholder text when no messages yet
|
||||
- Auto-scrolls to latest message via `useRef` + `scrollIntoView`
|
||||
- Auto-focuses input when panel opens (200ms delay for animation)
|
||||
- Responsive: on mobile (<640px), panel is full-width bottom sheet with rounded top corners; on desktop, 380px wide positioned above the button
|
||||
- Panel entrance: scale(0.95)+opacity(0) → scale(1)+opacity(1), 200ms. Exit: reverse, 150ms
|
||||
- Respects `prefers-reduced-motion` — skips all animation
|
||||
- Close button in header triggers `setIsOpen(false)` (same as floating button toggle)
|
||||
- Submitting appends both user message and placeholder assistant response to state
|
||||
- Typecheck, lint (0 errors), and build all pass
|
||||
- Browser verified: panel opens/closes correctly, messages display, input works, Enter submits, close button works
|
||||
- Files changed: `src/components/ChatWidget.tsx`
|
||||
- **Learnings for future iterations:**
|
||||
- `AnimatePresence` with `key` prop on the panel div is needed for exit animations to work
|
||||
- Panel uses `transformOrigin: 'bottom right'` for natural scale animation from the button corner
|
||||
- CSS-in-JS `<style>` tag with `data-chat-panel` attribute handles responsive width/height (Tailwind can't express max-height conditionally based on viewport width easily)
|
||||
- `textarea` with `rows={1}` and `maxHeight: 80px` gives auto-growing feel; `resize: none` prevents manual resize
|
||||
- The `ChatMessage` interface (`{ role, content }`) is ready to be extended for US-009 Gemini integration — same shape as typical LLM message format
|
||||
- `onFocus/onBlur` border color transitions on the textarea give a polished input interaction
|
||||
---
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user