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