From 5f3e0db7129216ce1b115d23420cf8bbe4907cf7 Mon Sep 17 00:00:00 2001 From: Andy Charlwood Date: Sun, 15 Feb 2026 18:30:07 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20US-010=20-=20Chat=20widget=20=E2=80=94?= =?UTF-8?q?=20clickable=20portfolio=20item=20cards=20in=20responses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Ralph/prd.json | 2 +- Ralph/progress.txt | 26 ++++ src/components/ChatWidget.tsx | 187 ++++++++++++++++++++++++----- src/components/CommandPalette.tsx | 32 +---- src/components/DashboardLayout.tsx | 2 +- src/lib/palette-icons.ts | 26 ++++ 6 files changed, 216 insertions(+), 59 deletions(-) create mode 100644 src/lib/palette-icons.ts diff --git a/Ralph/prd.json b/Ralph/prd.json index e3b6677..38185e3 100644 --- a/Ralph/prd.json +++ b/Ralph/prd.json @@ -193,7 +193,7 @@ "Verify in browser using dev-browser skill" ], "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." } ] diff --git a/Ralph/progress.txt b/Ralph/progress.txt index 17ce355..59ff531 100644 --- a/Ralph/progress.txt +++ b/Ralph/progress.txt @@ -25,6 +25,9 @@ - `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 `` 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 +- 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 `` 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 --- + +## 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) via `useMemo` for O(1) item lookups +- Added `getMessageItemIds()` to parse `` 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 `` 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 `` HTML comment regex `` 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 +--- diff --git a/src/components/ChatWidget.tsx b/src/components/ChatWidget.tsx index 5172b1e..b2de809 100644 --- a/src/components/ChatWidget.tsx +++ b/src/components/ChatWidget.tsx @@ -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([]) 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() + 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?/, '').trim() } + // Extract item IDs from the HTML comment in message content + const getMessageItemIds = (content: string): string[] => { + const match = content.match(//) + 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() { )} - {messages.map((msg, i) => ( -
+ {messages.map((msg, i) => { + const referencedItems = msg.role === 'assistant' ? getMessageItems(msg.content) : [] + + return (
- {getDisplayText(msg.content)} +
+
+ {getDisplayText(msg.content)} +
+ + {referencedItems.length > 0 && ( +
+ {referencedItems.map((item) => { + const IconComponent = iconByType[item.iconType] + const colorStyle = iconColorStyles[item.iconVariant] + + return ( + + ) + })} +
+ )} +
-
- ))} + ) + })} {/* Typing indicator */} {isStreaming && messages.length > 0 && messages[messages.length - 1].content === '' && ( diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 8e8cf8c..5bade87 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -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 = { - role: User, - skill: Activity, - project: Monitor, - achievement: Award, - edu: GraduationCap, - action: Zap, -} - -// Color variant → CSS variable mapping for icon containers -const iconColorStyles: Record = { - 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) diff --git a/src/components/DashboardLayout.tsx b/src/components/DashboardLayout.tsx index d705ab8..1e6dc3b 100644 --- a/src/components/DashboardLayout.tsx +++ b/src/components/DashboardLayout.tsx @@ -421,7 +421,7 @@ export function DashboardLayout() { {/* Floating chat widget */} - + ) } diff --git a/src/lib/palette-icons.ts b/src/lib/palette-icons.ts new file mode 100644 index 0000000..8fa5493 --- /dev/null +++ b/src/lib/palette-icons.ts @@ -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 = { + role: User, + skill: Activity, + project: Monitor, + achievement: Award, + edu: GraduationCap, + action: Zap, +} + +export const iconColorStyles: Record = { + 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' }, +}