AiAssistantDropdown.tsx212 lines · main
| 1 | import { AiAssistantSource } from 'common/telemetry-constants' |
| 2 | import { Chatgpt, Claude } from 'icons' |
| 3 | import { Check, ChevronDown, Copy } from 'lucide-react' |
| 4 | import Link from 'next/link' |
| 5 | import { ComponentProps, ReactNode, useEffect, useState } from 'react' |
| 6 | import { |
| 7 | AiIconAnimation, |
| 8 | Button, |
| 9 | cn, |
| 10 | copyToClipboard, |
| 11 | DropdownMenu, |
| 12 | DropdownMenuContent, |
| 13 | DropdownMenuItem, |
| 14 | DropdownMenuSeparator, |
| 15 | DropdownMenuTrigger, |
| 16 | Tooltip, |
| 17 | TooltipContent, |
| 18 | TooltipTrigger, |
| 19 | } from 'ui' |
| 20 | |
| 21 | import { useTrack } from '@/lib/telemetry/track' |
| 22 | |
| 23 | type TelemetrySource = AiAssistantSource |
| 24 | |
| 25 | const EXTERNAL_AI_TOOLS = [ |
| 26 | { |
| 27 | label: 'Ask ChatGPT', |
| 28 | url: 'https://chatgpt.com/', |
| 29 | promptParam: 'q', |
| 30 | icon: Chatgpt, |
| 31 | toolId: 'chatgpt' as const, |
| 32 | }, |
| 33 | { |
| 34 | label: 'Ask Claude', |
| 35 | url: 'https://claude.ai/new', |
| 36 | promptParam: 'q', |
| 37 | icon: Claude, |
| 38 | toolId: 'claude' as const, |
| 39 | }, |
| 40 | ] |
| 41 | |
| 42 | export interface AiAssistantDropdownItem { |
| 43 | label: string |
| 44 | icon?: ReactNode |
| 45 | href?: string |
| 46 | onClick?: () => void |
| 47 | } |
| 48 | |
| 49 | export interface AiAssistantDropdownProps { |
| 50 | buildPrompt: () => string |
| 51 | label: string |
| 52 | iconOnly?: boolean |
| 53 | onOpenAssistant: () => void |
| 54 | onCopyPrompt?: () => void |
| 55 | telemetrySource?: TelemetrySource |
| 56 | size?: ComponentProps<typeof Button>['size'] |
| 57 | type?: ComponentProps<typeof Button>['type'] |
| 58 | disabled?: boolean |
| 59 | loading?: boolean |
| 60 | className?: string |
| 61 | tooltip?: string |
| 62 | copyLabel?: string |
| 63 | showExternalAI?: boolean |
| 64 | additionalDropdownItems?: AiAssistantDropdownItem[] |
| 65 | } |
| 66 | |
| 67 | export function AiAssistantDropdown({ |
| 68 | buildPrompt, |
| 69 | label, |
| 70 | iconOnly = false, |
| 71 | onOpenAssistant, |
| 72 | onCopyPrompt, |
| 73 | telemetrySource, |
| 74 | size = 'tiny', |
| 75 | type = 'default', |
| 76 | disabled = false, |
| 77 | loading = false, |
| 78 | className, |
| 79 | tooltip, |
| 80 | copyLabel = 'Copy prompt', |
| 81 | showExternalAI = false, |
| 82 | additionalDropdownItems, |
| 83 | }: AiAssistantDropdownProps) { |
| 84 | const track = useTrack() |
| 85 | const [showCopied, setShowCopied] = useState(false) |
| 86 | const [isOpen, setIsOpen] = useState(false) |
| 87 | |
| 88 | useEffect(() => { |
| 89 | if (!showCopied) return |
| 90 | const timer = setTimeout(() => setShowCopied(false), 2000) |
| 91 | return () => clearTimeout(timer) |
| 92 | }, [showCopied]) |
| 93 | |
| 94 | const handleCopyPrompt = () => { |
| 95 | const prompt = buildPrompt() |
| 96 | copyToClipboard(prompt) |
| 97 | setShowCopied(true) |
| 98 | setIsOpen(false) |
| 99 | onCopyPrompt?.() |
| 100 | |
| 101 | if (telemetrySource) { |
| 102 | track('ai_prompt_copied', { source: telemetrySource }) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | const handleOpenExternalAI = (tool: (typeof EXTERNAL_AI_TOOLS)[number]) => { |
| 107 | const prompt = buildPrompt() |
| 108 | window.open( |
| 109 | `${tool.url}?${tool.promptParam}=${encodeURIComponent(prompt)}`, |
| 110 | '_blank', |
| 111 | 'noreferrer' |
| 112 | ) |
| 113 | |
| 114 | if (telemetrySource) { |
| 115 | track('ai_external_tool_clicked', { source: telemetrySource, tool: tool.toolId }) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | const handleOpenAssistant = () => { |
| 120 | onOpenAssistant() |
| 121 | |
| 122 | if (telemetrySource) { |
| 123 | track('ai_assistant_dropdown_button_clicked', { source: telemetrySource }) |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | const buttonContent = ( |
| 128 | <div className={cn('flex items-center', iconOnly ? 'gap-0' : 'gap-0')}> |
| 129 | {/* Main button */} |
| 130 | <Button |
| 131 | type={type} |
| 132 | size={size} |
| 133 | disabled={disabled} |
| 134 | onClick={handleOpenAssistant} |
| 135 | icon={<AiIconAnimation size={iconOnly ? 16 : 14} loading={loading} />} |
| 136 | className={cn('rounded-r-none border-r-0', iconOnly && 'px-1.5', className)} |
| 137 | > |
| 138 | {!iconOnly && label} |
| 139 | </Button> |
| 140 | |
| 141 | {/* Dropdown trigger */} |
| 142 | <DropdownMenu open={isOpen} onOpenChange={setIsOpen}> |
| 143 | <DropdownMenuTrigger asChild> |
| 144 | <Button |
| 145 | type={type} |
| 146 | size={size} |
| 147 | disabled={disabled} |
| 148 | className={cn('rounded-l-none px-1', iconOnly && 'px-1')} |
| 149 | icon={<ChevronDown size={12} />} |
| 150 | /> |
| 151 | </DropdownMenuTrigger> |
| 152 | <DropdownMenuContent align="end" className="w-44"> |
| 153 | <DropdownMenuItem onClick={handleCopyPrompt} className="gap-2"> |
| 154 | {showCopied ? <Check size={14} className="text-brand" /> : <Copy size={14} />} |
| 155 | {showCopied ? 'Copied!' : copyLabel} |
| 156 | </DropdownMenuItem> |
| 157 | |
| 158 | {showExternalAI && ( |
| 159 | <> |
| 160 | <DropdownMenuSeparator /> |
| 161 | {EXTERNAL_AI_TOOLS.map((tool) => ( |
| 162 | <DropdownMenuItem |
| 163 | key={tool.url} |
| 164 | className="gap-2" |
| 165 | onClick={() => handleOpenExternalAI(tool)} |
| 166 | > |
| 167 | <tool.icon size={14} /> |
| 168 | {tool.label} |
| 169 | </DropdownMenuItem> |
| 170 | ))} |
| 171 | </> |
| 172 | )} |
| 173 | |
| 174 | {additionalDropdownItems && additionalDropdownItems.length > 0 && ( |
| 175 | <> |
| 176 | <DropdownMenuSeparator /> |
| 177 | {additionalDropdownItems.map((item, i) => ( |
| 178 | <DropdownMenuItem key={i} onClick={item.onClick} className="gap-2"> |
| 179 | {item.href ? ( |
| 180 | <Link href={item.href} target="_blank" rel="noreferrer"> |
| 181 | {item.icon} |
| 182 | {item.label} |
| 183 | </Link> |
| 184 | ) : ( |
| 185 | <> |
| 186 | {item.icon} |
| 187 | {item.label} |
| 188 | </> |
| 189 | )} |
| 190 | </DropdownMenuItem> |
| 191 | ))} |
| 192 | </> |
| 193 | )} |
| 194 | </DropdownMenuContent> |
| 195 | </DropdownMenu> |
| 196 | </div> |
| 197 | ) |
| 198 | |
| 199 | // Wrap in tooltip for icon-only mode |
| 200 | if (iconOnly && tooltip) { |
| 201 | return ( |
| 202 | <Tooltip> |
| 203 | <TooltipTrigger asChild> |
| 204 | <div className="inline-flex">{buttonContent}</div> |
| 205 | </TooltipTrigger> |
| 206 | <TooltipContent side="bottom">{tooltip}</TooltipContent> |
| 207 | </Tooltip> |
| 208 | ) |
| 209 | } |
| 210 | |
| 211 | return buttonContent |
| 212 | } |