AiAssistantDropdown.tsx212 lines · main
1import { AiAssistantSource } from 'common/telemetry-constants'
2import { Chatgpt, Claude } from 'icons'
3import { Check, ChevronDown, Copy } from 'lucide-react'
4import Link from 'next/link'
5import { ComponentProps, ReactNode, useEffect, useState } from 'react'
6import {
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
21import { useTrack } from '@/lib/telemetry/track'
22
23type TelemetrySource = AiAssistantSource
24
25const 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
42export interface AiAssistantDropdownItem {
43 label: string
44 icon?: ReactNode
45 href?: string
46 onClick?: () => void
47}
48
49export 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
67export 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}