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
+1 -1
View File
@@ -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."
},
{
+30
View File
@@ -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
---
+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 */}