feat: US-010 - Chat widget — clickable portfolio item cards in responses

This commit is contained in:
2026-02-15 18:30:07 +00:00
parent 29e1728e11
commit 5f3e0db712
6 changed files with 216 additions and 59 deletions
+159 -28
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 'react'
import { useState, useRef, useEffect, useCallback, useMemo, type KeyboardEvent } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { MessageCircle, X, Send, Loader2 } from 'lucide-react'
import {
@@ -8,6 +8,9 @@ import {
stripItemsSuffix,
type ChatMessage,
} from '@/lib/gemini'
import { buildPaletteData } from '@/lib/search'
import type { PaletteItem, PaletteAction } from '@/lib/search'
import { iconByType, iconColorStyles } from '@/lib/palette-icons'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
@@ -42,7 +45,11 @@ const panelVariants = {
: { opacity: 0, scale: 0.95, transition: { duration: 0.15, ease: 'easeIn' } },
}
export function ChatWidget() {
interface ChatWidgetProps {
onAction?: (action: PaletteAction) => void
}
export function ChatWidget({ onAction }: ChatWidgetProps) {
const [isOpen, setIsOpen] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [inputValue, setInputValue] = useState('')
@@ -52,6 +59,14 @@ export function ChatWidget() {
const geminiAvailable = isGeminiAvailable()
// Build palette map for looking up items by ID
const paletteMap = useMemo(() => {
const items = buildPaletteData()
const map = new Map<string, PaletteItem>()
for (const item of items) map.set(item.id, item)
return map
}, [])
// Auto-scroll to latest message
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -138,6 +153,31 @@ export function ChatWidget() {
return content.replace(/\n?<!--ITEMS:[^>]*-->/, '').trim()
}
// Extract item IDs from the <!--ITEMS:...--> HTML comment in message content
const getMessageItemIds = (content: string): string[] => {
const match = content.match(/<!--ITEMS:([^>]*)-->/)
if (!match) return []
return match[1].split(',').map((id) => id.trim()).filter(Boolean)
}
// Resolve item IDs to PaletteItems
const getMessageItems = (content: string): PaletteItem[] => {
return getMessageItemIds(content)
.map((id) => paletteMap.get(id))
.filter((item): item is PaletteItem => item !== undefined)
}
// Handle clicking an item card — route through onAction
const handleItemClick = useCallback((item: PaletteItem) => {
if (onAction) {
onAction(item.action)
} else {
if (item.action.type === 'link') {
window.open(item.action.url, '_blank', 'noopener,noreferrer')
}
}
}, [onAction])
return (
<>
{/* Chat panel */}
@@ -270,37 +310,128 @@ export function ChatWidget() {
</div>
)}
{messages.map((msg, i) => (
<div
key={i}
style={{
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
}}
>
{messages.map((msg, i) => {
const referencedItems = msg.role === 'assistant' ? getMessageItems(msg.content) : []
return (
<div
key={i}
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)',
whiteSpace: 'pre-wrap',
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
}}
>
{getDisplayText(msg.content)}
<div
style={{
maxWidth: '85%',
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)',
overflow: 'hidden',
}}
>
<div style={{ padding: '10px 14px', whiteSpace: 'pre-wrap' }}>
{getDisplayText(msg.content)}
</div>
{referencedItems.length > 0 && (
<div
style={{
borderTop: '1px solid var(--border-light)',
padding: '6px 8px',
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
{referencedItems.map((item) => {
const IconComponent = iconByType[item.iconType]
const colorStyle = iconColorStyles[item.iconVariant]
return (
<button
key={item.id}
onClick={() => handleItemClick(item)}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '6px 8px',
borderRadius: '6px',
border: 'none',
background: 'transparent',
cursor: 'pointer',
width: '100%',
textAlign: 'left',
transition: 'background-color 100ms ease-out',
fontSize: '12px',
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent-light)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
}}
>
<div
style={{
width: '22px',
height: '22px',
borderRadius: '5px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
background: colorStyle.background,
color: colorStyle.color,
}}
>
{IconComponent && <IconComponent size={12} />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 500,
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{item.title}
</div>
<div
style={{
fontSize: '11px',
color: 'var(--text-tertiary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
marginTop: '-1px',
}}
>
{item.subtitle}
</div>
</div>
</button>
)
})}
</div>
)}
</div>
</div>
</div>
))}
)
})}
{/* Typing indicator */}
{isStreaming && messages.length > 0 && messages[messages.length - 1].content === '' && (
+3 -29
View File
@@ -1,20 +1,12 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import {
Search,
User,
Activity,
Monitor,
Award,
GraduationCap,
Zap,
type LucideIcon,
} from 'lucide-react'
import { Search } from 'lucide-react'
import {
buildPaletteData,
buildSearchIndex,
groupBySection,
} from '@/lib/search'
import type { PaletteItem, PaletteAction, IconColorVariant } from '@/lib/search'
import type { PaletteItem, PaletteAction } from '@/lib/search'
import { iconByType, iconColorStyles } from '@/lib/palette-icons'
import { isModelReady, embedQuery } from '@/lib/embedding-model'
import { semanticSearch, loadEmbeddings } from '@/lib/semantic-search'
@@ -26,24 +18,6 @@ interface CommandPaletteProps {
onAction?: (action: PaletteAction) => void
}
// Icon mapping by type
const iconByType: Record<string, LucideIcon> = {
role: User,
skill: Activity,
project: Monitor,
achievement: Award,
edu: GraduationCap,
action: Zap,
}
// Color variant → CSS variable mapping for icon containers
const iconColorStyles: Record<IconColorVariant, { background: string; color: string }> = {
teal: { background: 'var(--accent-light)', color: 'var(--accent)' },
green: { background: 'var(--success-light)', color: 'var(--success)' },
amber: { background: 'var(--amber-light)', color: 'var(--amber)' },
purple: { background: 'rgba(124,58,237,0.08)', color: '#7C3AED' },
}
export function CommandPalette({ isOpen, onClose, onAction }: CommandPaletteProps) {
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(-1)
+1 -1
View File
@@ -421,7 +421,7 @@ export function DashboardLayout() {
<DetailPanel />
{/* Floating chat widget */}
<ChatWidget />
<ChatWidget onAction={handlePaletteAction} />
</div>
)
}