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"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 8,
|
"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."
|
"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
|
- `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)
|
- 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
|
- `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
|
- 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)
|
- 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 { 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
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
const buttonVariants = {
|
const buttonVariants = {
|
||||||
hidden: prefersReducedMotion
|
hidden: prefersReducedMotion
|
||||||
? { opacity: 1, y: 0 }
|
? { 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() {
|
export function ChatWidget() {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Chat panel placeholder — wired in US-008 */}
|
{/* Chat panel */}
|
||||||
<AnimatePresence>
|
<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>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Floating chat button */}
|
{/* Floating chat button */}
|
||||||
|
|||||||
Reference in New Issue
Block a user