AdvisorSection.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. import { useParams } from 'common'
  2. import { BarChart, Shield } from 'lucide-react'
  3. import { useCallback, useMemo } from 'react'
  4. import { AiIconAnimation, Badge, Button, Card, CardContent, CardHeader, CardTitle, cn } from 'ui'
  5. import { Row } from 'ui-patterns'
  6. import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
  7. import { Markdown } from '../Markdown'
  8. import { LINTER_LEVELS } from '@/components/interfaces/Linter/Linter.constants'
  9. import { createLintSummaryPrompt } from '@/components/interfaces/Linter/Linter.utils'
  10. import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
  11. import type { AdvisorItem } from '@/components/ui/AdvisorPanel/AdvisorPanel.types'
  12. import {
  13. createAdvisorLintItems,
  14. getAdvisorItemDisplayTitle,
  15. MAX_HOMEPAGE_ADVISOR_ITEMS,
  16. severityBadgeVariants,
  17. severityColorClasses,
  18. sortAdvisorItems,
  19. } from '@/components/ui/AdvisorPanel/AdvisorPanel.utils'
  20. import { useAdvisorSignals } from '@/components/ui/AdvisorPanel/useAdvisorSignals'
  21. import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
  22. import { useProjectLintsQuery } from '@/data/lint/lint-query'
  23. import { useTrack } from '@/lib/telemetry/track'
  24. import { useAdvisorStateSnapshot } from '@/state/advisor-state'
  25. import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
  26. import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
  27. export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: boolean }) => {
  28. const { ref: projectRef } = useParams()
  29. const track = useTrack()
  30. const snap = useAiAssistantStateSnapshot()
  31. const { openSidebar } = useSidebarManagerSnapshot()
  32. const { setSelectedItem } = useAdvisorStateSnapshot()
  33. const { data: lints, isLoading: isLoadingLints } = useProjectLintsQuery(
  34. { projectRef },
  35. { enabled: !showEmptyState }
  36. )
  37. const { data: signalItems } = useAdvisorSignals({ projectRef, enabled: !showEmptyState })
  38. const advisorItems = useMemo<AdvisorItem[]>(() => {
  39. const criticalLintItems = createAdvisorLintItems(lints).filter(
  40. (item) => item.source === 'lint' && item.original.level === LINTER_LEVELS.ERROR
  41. )
  42. return sortAdvisorItems([...criticalLintItems, ...signalItems])
  43. }, [lints, signalItems])
  44. const visibleAdvisorItems = useMemo(
  45. () => advisorItems.slice(0, MAX_HOMEPAGE_ADVISOR_ITEMS),
  46. [advisorItems]
  47. )
  48. const totalIssues = advisorItems.length
  49. const hiddenIssuesCount = totalIssues - visibleAdvisorItems.length
  50. const titleContent = useMemo(() => {
  51. if (totalIssues === 0) return <h2>Advisor found no issues</h2>
  52. const issuesText = totalIssues === 1 ? 'issue' : 'issues'
  53. const numberDisplay = totalIssues.toString()
  54. return (
  55. <h2>
  56. Advisor found {numberDisplay} {issuesText}
  57. </h2>
  58. )
  59. }, [totalIssues])
  60. const handleAskAssistant = useCallback(() => {
  61. openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
  62. track('advisor_assistant_button_clicked', {
  63. origin: 'homepage',
  64. issuesCount: totalIssues,
  65. })
  66. }, [track, openSidebar, totalIssues])
  67. const handleCardClick = useCallback(
  68. (item: AdvisorItem) => {
  69. setSelectedItem(item.id, item.source)
  70. openSidebar(SIDEBAR_KEYS.ADVISOR_PANEL)
  71. const advisorCategory =
  72. item.source === 'lint'
  73. ? item.original.categories.includes('SECURITY')
  74. ? 'SECURITY'
  75. : item.original.categories.includes('PERFORMANCE')
  76. ? 'PERFORMANCE'
  77. : undefined
  78. : item.source === 'signal'
  79. ? 'SECURITY'
  80. : undefined
  81. const advisorType =
  82. item.source === 'signal'
  83. ? item.type
  84. : item.source === 'lint'
  85. ? item.original.name
  86. : item.title
  87. const advisorLevel = item.source === 'lint' ? item.original.level : undefined
  88. track('advisor_detail_opened', {
  89. origin: 'homepage',
  90. advisorSource: item.source,
  91. advisorCategory,
  92. advisorType,
  93. advisorLevel,
  94. })
  95. },
  96. [track, setSelectedItem, openSidebar]
  97. )
  98. if (showEmptyState) {
  99. return <EmptyState />
  100. }
  101. // [Joshen] Note that we're intentionally (for now) not waiting for advisor signals to load
  102. // render main content as long as the main lints have been fetched
  103. return (
  104. <div>
  105. {isLoadingLints ? (
  106. <ShimmeringLoader className="w-96 mb-6" />
  107. ) : (
  108. <div className="flex justify-between items-center mb-6">
  109. {titleContent}
  110. <Button type="default" icon={<AiIconAnimation />} onClick={handleAskAssistant}>
  111. Ask Assistant
  112. </Button>
  113. </div>
  114. )}
  115. {isLoadingLints ? (
  116. <div className="flex flex-col gap-2">
  117. <ShimmeringLoader />
  118. <ShimmeringLoader className="w-3/4" />
  119. <ShimmeringLoader className="w-1/2" />
  120. </div>
  121. ) : visibleAdvisorItems.length > 0 ? (
  122. <>
  123. <Row maxColumns={4} minWidth={280}>
  124. {visibleAdvisorItems.map((item) => {
  125. const isLint = item.source === 'lint'
  126. const categoryLabel = item.tab === 'performance' ? 'PERFORMANCE' : 'SECURITY'
  127. const title = getAdvisorItemDisplayTitle(item)
  128. const description =
  129. item.source === 'signal' ? item.summary : isLint ? item.original.detail : ''
  130. const cardClasses =
  131. item.severity === 'critical'
  132. ? 'bg-destructive-200 border-destructive-400'
  133. : item.severity === 'warning'
  134. ? 'border-warning-400'
  135. : ''
  136. return (
  137. <Card
  138. key={`${item.source}-${item.id}`}
  139. className={cn(
  140. 'min-h-full flex flex-col items-stretch cursor-pointer h-64',
  141. cardClasses
  142. )}
  143. onClick={() => {
  144. handleCardClick(item)
  145. }}
  146. >
  147. <CardHeader className="border-b-0 shrink-0 flex flex-row gap-2 space-y-0 justify-between items-center">
  148. <div className="flex flex-row items-center gap-3">
  149. {item.tab === 'security' ? (
  150. <Shield
  151. size={16}
  152. strokeWidth={1.5}
  153. className={severityColorClasses[item.severity]}
  154. />
  155. ) : (
  156. <BarChart
  157. size={16}
  158. strokeWidth={1.5}
  159. className={severityColorClasses[item.severity]}
  160. />
  161. )}
  162. <CardTitle className="text-foreground-light">{categoryLabel}</CardTitle>
  163. </div>
  164. <div className="flex items-center gap-2">
  165. <Badge variant={severityBadgeVariants[item.severity]} className="w-fit">
  166. {item.severity.toUpperCase()}
  167. </Badge>
  168. {isLint && (
  169. <div
  170. onClick={(e) => {
  171. e.stopPropagation()
  172. e.preventDefault()
  173. }}
  174. >
  175. <AiAssistantDropdown
  176. label="Ask Assistant"
  177. iconOnly
  178. tooltip="Help me fix this issue"
  179. buildPrompt={() => createLintSummaryPrompt(item.original)}
  180. onOpenAssistant={() => {
  181. openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
  182. snap.newChat({
  183. name: 'Summarise lint',
  184. initialInput: createLintSummaryPrompt(item.original),
  185. })
  186. track('advisor_assistant_button_clicked', {
  187. origin: 'homepage',
  188. advisorCategory: item.original.categories[0],
  189. advisorType: item.original.name,
  190. advisorLevel: item.original.level,
  191. })
  192. }}
  193. telemetrySource="advisor_section"
  194. type="text"
  195. className="w-7 h-7"
  196. />
  197. </div>
  198. )}
  199. </div>
  200. </CardHeader>
  201. <CardContent className="p-6 pt-16 flex flex-col justify-end flex-1 overflow-auto">
  202. <h3 className="mb-1">{title}</h3>
  203. <Markdown className="leading-6 text-sm text-foreground-light">
  204. {description && description.replace(/\\`/g, '`')}
  205. </Markdown>
  206. </CardContent>
  207. </Card>
  208. )
  209. })}
  210. </Row>
  211. {hiddenIssuesCount > 0 && (
  212. <div className="mt-4 flex justify-end">
  213. <Button type="text" onClick={() => openSidebar(SIDEBAR_KEYS.ADVISOR_PANEL)}>
  214. View {hiddenIssuesCount} more issue{hiddenIssuesCount !== 1 ? 's' : ''} in Advisor
  215. </Button>
  216. </div>
  217. )}
  218. </>
  219. ) : (
  220. <EmptyState />
  221. )}
  222. </div>
  223. )
  224. }
  225. function EmptyState() {
  226. return (
  227. <Card className="bg-transparent h-64">
  228. <CardContent className="flex flex-col items-center justify-center gap-2 p-16 h-full">
  229. <Shield size={20} strokeWidth={1.5} className="text-foreground-muted" />
  230. <p className="text-sm text-foreground-light text-center">
  231. No security or performance issues found
  232. </p>
  233. </CardContent>
  234. </Card>
  235. )
  236. }