'use client' /** * One-component-fits-all "Ask" affordance shared across the three * chat-adjacent surfaces (source chips, inline cards, search results). * * Before this primitive, the same semantic action was implemented three * different ways: * - Source chip: inline `` * - Search results: nothing (asymmetric — no Ask) * * Three icon sizes, three different opacity rules, three different DOM * shapes for the same action. Centralized here as a single `Button`- * backed wrapper so the DOM + a11y + hover behavior live in one place. * The `density` prop picks the visual variant; the icon/label/click * semantics are identical across all three. */ import React from 'react' import { MessageSquare, FileText } from 'lucide-react' import { Button } from '../ui/button' import type { ChatRef } from './chat-ref.types' export type SourceActionDensity = 'inline' | 'card' | 'list-row' export interface SourceActionButtonProps { /** The row to drill into when clicked. The button is HIDDEN when this * is null (e.g. row has no primary key — Ask can't fire). */ chatRef: ChatRef | null /** Click handler. Receives the chatRef. When undefined the button is * hidden — same as null chatRef. */ onDiscuss?: (ref: ChatRef) => void /** Visual density / layout. Defaults to `'inline'`. */ density?: SourceActionDensity /** Optional className appended to the outer element. */ className?: string /** Button text label override. Defaults to `'Ask'`. The Display variant * on inline cards passes `'Display'` here. Density `'inline'` shows * the icon only — the label is used for aria-label / title even when * it doesn't render visibly. */ label?: string } // Per-density styling lives in one map so the three call sites share the // SAME hover/opacity/transition rules. Adding a fourth density means // adding one entry here, not touching the render path. const DENSITY_STYLES: Record< SourceActionDensity, { size: 'small' | 'icon' showLabel: boolean iconSize: string className: string } > = { inline: { size: 'icon', showLabel: false, iconSize: '!h-3 !w-3', className: '!h-5 !w-5 !p-0 text-ods-text-secondary opacity-60 hover:opacity-100 hover:text-ods-text-primary hover:bg-ods-accent/15', }, card: { size: 'small', showLabel: true, iconSize: '!h-3 !w-3', className: '!h-auto !p-0 text-h6 font-normal text-ods-text-secondary opacity-60 hover:bg-transparent hover:opacity-100 hover:text-ods-text-primary', }, 'list-row': { size: 'small', showLabel: true, iconSize: '!h-3 !w-3', className: '!h-auto !px-1.5 !py-0.5 text-h6 font-normal text-ods-text-secondary opacity-60 hover:opacity-100 hover:text-ods-text-primary hover:bg-ods-accent/10', }, } export function SourceActionButton({ chatRef, onDiscuss, density = 'inline', className, label = 'Ask', }: SourceActionButtonProps): React.ReactElement | null { if (!chatRef || !onDiscuss) return null // Label-driven icon. "Display" reads as a verbatim-content action so a // FileText icon reinforces "show me the body" semantics; the default // "Ask" stays MessageSquare for the conversational connotation. Any // future label adds an entry to this map. const isDisplay = label === 'Display' const verbLabel = isDisplay ? `Display ${chatRef.title || 'this item'}` : `Ask about ${chatRef.title || 'this item'}` const handle = (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() onDiscuss(chatRef) } const styles = DENSITY_STYLES[density] const IconComponent = isDisplay ? FileText : MessageSquare const icon = return ( ) }