AdvisorSection.tsx255 lines · main
| 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 | |
| 8 | import { Markdown } from '../Markdown' |
| 9 | import { LINTER_LEVELS } from '@/components/interfaces/Linter/Linter.constants' |
| 10 | import { createLintSummaryPrompt } from '@/components/interfaces/Linter/Linter.utils' |
| 11 | import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' |
| 12 | import type { AdvisorItem } from '@/components/ui/AdvisorPanel/AdvisorPanel.types' |
| 13 | import { |
| 14 | createAdvisorLintItems, |
| 15 | getAdvisorItemDisplayTitle, |
| 16 | MAX_HOMEPAGE_ADVISOR_ITEMS, |
| 17 | severityBadgeVariants, |
| 18 | severityColorClasses, |
| 19 | sortAdvisorItems, |
| 20 | } from '@/components/ui/AdvisorPanel/AdvisorPanel.utils' |
| 21 | import { useAdvisorSignals } from '@/components/ui/AdvisorPanel/useAdvisorSignals' |
| 22 | import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' |
| 23 | import { useProjectLintsQuery } from '@/data/lint/lint-query' |
| 24 | import { useTrack } from '@/lib/telemetry/track' |
| 25 | import { useAdvisorStateSnapshot } from '@/state/advisor-state' |
| 26 | import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' |
| 27 | import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' |
| 28 | |
| 29 | export 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 | |
| 244 | function 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 | } |