AiAssistantDropdown.tsx 5.6 KB

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