feat: US-010 - Chat widget — clickable portfolio item cards in responses
This commit is contained in:
+1
-1
@@ -193,7 +193,7 @@
|
|||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 10,
|
"priority": 10,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": "The action routing needs to flow from ChatWidget up to DashboardLayout. Add an onAction prop to ChatWidget (same pattern as CommandPalette). DashboardLayout passes handlePaletteAction to ChatWidget. Export iconByType and iconColorStyles from CommandPalette (or extract to a shared module) so ChatWidget can reuse them."
|
"notes": "The action routing needs to flow from ChatWidget up to DashboardLayout. Add an onAction prop to ChatWidget (same pattern as CommandPalette). DashboardLayout passes handlePaletteAction to ChatWidget. Export iconByType and iconColorStyles from CommandPalette (or extract to a shared module) so ChatWidget can reuse them."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
- `isGeminiAvailable()` checks `import.meta.env.VITE_GEMINI_API_KEY` — when missing, chat panel shows "unavailable" message but button remains visible
|
- `isGeminiAvailable()` checks `import.meta.env.VITE_GEMINI_API_KEY` — when missing, chat panel shows "unavailable" message but button remains visible
|
||||||
- Assistant messages store item IDs as `<!--ITEMS:id1,id2-->` HTML comment suffix for US-010 to parse — `getDisplayText()` strips this before rendering
|
- Assistant messages store item IDs as `<!--ITEMS:id1,id2-->` HTML comment suffix for US-010 to parse — `getDisplayText()` strips this before rendering
|
||||||
- Conversation history capped at 10 messages (`MAX_HISTORY`), metadata stripped before sending to API
|
- Conversation history capped at 10 messages (`MAX_HISTORY`), metadata stripped before sending to API
|
||||||
|
- Icon/color mappings (`iconByType`, `iconColorStyles`) live in `src/lib/palette-icons.ts` — shared between CommandPalette and ChatWidget
|
||||||
|
- ChatWidget accepts optional `onAction?: (action: PaletteAction) => void` prop — same pattern as CommandPalette's `onAction`
|
||||||
|
- `DashboardLayout` passes `handlePaletteAction` to both CommandPalette and ChatWidget for unified action routing
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -200,3 +203,26 @@
|
|||||||
- The `<!--ITEMS:-->` HTML comment pattern is invisible when rendered but parseable by US-010 for item card display
|
- The `<!--ITEMS:-->` HTML comment pattern is invisible when rendered but parseable by US-010 for item card display
|
||||||
- `useCallback` on `handleSubmit` with `[inputValue, isStreaming, messages]` deps is needed because it reads all three
|
- `useCallback` on `handleSubmit` with `[inputValue, isStreaming, messages]` deps is needed because it reads all three
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-02-15 - US-010
|
||||||
|
- Extracted `iconByType` and `iconColorStyles` from `CommandPalette.tsx` into shared `src/lib/palette-icons.ts`
|
||||||
|
- Updated `CommandPalette.tsx` to import from the shared module (no behavioral change)
|
||||||
|
- Added `onAction?: (action: PaletteAction) => void` prop to `ChatWidget` — same pattern as `CommandPalette`
|
||||||
|
- `DashboardLayout.tsx` passes `handlePaletteAction` to `ChatWidget` (same handler used by CommandPalette)
|
||||||
|
- ChatWidget builds a `paletteMap` (Map<id, PaletteItem>) via `useMemo` for O(1) item lookups
|
||||||
|
- Added `getMessageItemIds()` to parse `<!--ITEMS:id1,id2-->` HTML comments from message content
|
||||||
|
- Added `getMessageItems()` to resolve parsed IDs to PaletteItem objects via the map
|
||||||
|
- Assistant message bubbles now render compact clickable item cards below text when items are referenced:
|
||||||
|
- Cards use same icon/color scheme from CommandPalette (22px icon + title + subtitle)
|
||||||
|
- Cards have hover highlight (`var(--accent-light)`) and trigger `onAction(item.action)` on click
|
||||||
|
- Cards only appear after streaming completes (when `<!--ITEMS:-->` metadata is in final content)
|
||||||
|
- If no items referenced or IDs don't match, no cards shown — just text
|
||||||
|
- Typecheck, lint (0 errors), and build all pass
|
||||||
|
- Files changed: `src/lib/palette-icons.ts` (new), `src/components/ChatWidget.tsx`, `src/components/CommandPalette.tsx`, `src/components/DashboardLayout.tsx`
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Extracting shared constants to `src/lib/` is the right pattern — both `CommandPalette` and `ChatWidget` now use the same icon mappings without duplication
|
||||||
|
- `buildPaletteData()` is pure (no side effects) and idempotent — safe to call in `useMemo` with empty deps
|
||||||
|
- The `<!--ITEMS:-->` HTML comment regex `<!--ITEMS:([^>]*)-->` works reliably; `[^>]*` captures everything between the colons and closing
|
||||||
|
- Item card buttons use `fontFamily: 'inherit'` to pick up the panel's `font-ui` — without this, browser defaults apply
|
||||||
|
- The `overflow: 'hidden'` on the message bubble container is needed so the item cards section (with its own border-top) stays visually contained within the bubble's border-radius
|
||||||
|
---
|
||||||
|
|||||||
@@ -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 { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { MessageCircle, X, Send, Loader2 } from 'lucide-react'
|
import { MessageCircle, X, Send, Loader2 } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
stripItemsSuffix,
|
stripItemsSuffix,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
} from '@/lib/gemini'
|
} 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
|
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' } },
|
: { 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 [isOpen, setIsOpen] = useState(false)
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
const [inputValue, setInputValue] = useState('')
|
const [inputValue, setInputValue] = useState('')
|
||||||
@@ -52,6 +59,14 @@ export function ChatWidget() {
|
|||||||
|
|
||||||
const geminiAvailable = isGeminiAvailable()
|
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
|
// Auto-scroll to latest message
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
@@ -138,6 +153,31 @@ export function ChatWidget() {
|
|||||||
return content.replace(/\n?<!--ITEMS:[^>]*-->/, '').trim()
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Chat panel */}
|
{/* Chat panel */}
|
||||||
@@ -270,7 +310,10 @@ export function ChatWidget() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => {
|
||||||
|
const referencedItems = msg.role === 'assistant' ? getMessageItems(msg.content) : []
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
style={{
|
style={{
|
||||||
@@ -281,7 +324,6 @@ export function ChatWidget() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '85%',
|
maxWidth: '85%',
|
||||||
padding: '10px 14px',
|
|
||||||
borderRadius: msg.role === 'user'
|
borderRadius: msg.role === 'user'
|
||||||
? '12px 12px 4px 12px'
|
? '12px 12px 4px 12px'
|
||||||
: '12px 12px 12px 4px',
|
: '12px 12px 12px 4px',
|
||||||
@@ -294,13 +336,102 @@ export function ChatWidget() {
|
|||||||
border: msg.role === 'user'
|
border: msg.role === 'user'
|
||||||
? '1px solid var(--accent-border)'
|
? '1px solid var(--accent-border)'
|
||||||
: '1px solid var(--border-light)',
|
: '1px solid var(--border-light)',
|
||||||
whiteSpace: 'pre-wrap',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div style={{ padding: '10px 14px', whiteSpace: 'pre-wrap' }}>
|
||||||
{getDisplayText(msg.content)}
|
{getDisplayText(msg.content)}
|
||||||
</div>
|
</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>
|
||||||
))}
|
<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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Typing indicator */}
|
{/* Typing indicator */}
|
||||||
{isStreaming && messages.length > 0 && messages[messages.length - 1].content === '' && (
|
{isStreaming && messages.length > 0 && messages[messages.length - 1].content === '' && (
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||||
import {
|
import { Search } from 'lucide-react'
|
||||||
Search,
|
|
||||||
User,
|
|
||||||
Activity,
|
|
||||||
Monitor,
|
|
||||||
Award,
|
|
||||||
GraduationCap,
|
|
||||||
Zap,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import {
|
import {
|
||||||
buildPaletteData,
|
buildPaletteData,
|
||||||
buildSearchIndex,
|
buildSearchIndex,
|
||||||
groupBySection,
|
groupBySection,
|
||||||
} from '@/lib/search'
|
} 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 { isModelReady, embedQuery } from '@/lib/embedding-model'
|
||||||
import { semanticSearch, loadEmbeddings } from '@/lib/semantic-search'
|
import { semanticSearch, loadEmbeddings } from '@/lib/semantic-search'
|
||||||
|
|
||||||
@@ -26,24 +18,6 @@ interface CommandPaletteProps {
|
|||||||
onAction?: (action: PaletteAction) => void
|
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) {
|
export function CommandPalette({ isOpen, onClose, onAction }: CommandPaletteProps) {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ export function DashboardLayout() {
|
|||||||
<DetailPanel />
|
<DetailPanel />
|
||||||
|
|
||||||
{/* Floating chat widget */}
|
{/* Floating chat widget */}
|
||||||
<ChatWidget />
|
<ChatWidget onAction={handlePaletteAction} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
User,
|
||||||
|
Activity,
|
||||||
|
Monitor,
|
||||||
|
Award,
|
||||||
|
GraduationCap,
|
||||||
|
Zap,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { IconColorVariant } from '@/lib/search'
|
||||||
|
|
||||||
|
export const iconByType: Record<string, LucideIcon> = {
|
||||||
|
role: User,
|
||||||
|
skill: Activity,
|
||||||
|
project: Monitor,
|
||||||
|
achievement: Award,
|
||||||
|
edu: GraduationCap,
|
||||||
|
action: Zap,
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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' },
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user