Skip to content

Commit 70d7f7f

Browse files
committed
feat(frontend): add ResultCard component with expand/collapse and AI summary
- Created ResultCard component with animated collapsible code blocks - Smooth height transition on expand/collapse (200ms ease-out) - First result auto-expanded with TOP MATCH badge - AI summary section for top result - Visual match score with gradient progress bar - Copy code and View on GitHub actions - Fixed GitHub URL to strip internal repo ID prefix - Refactored SearchPanel to use ResultCard Closes #112
1 parent a3b2e5a commit 70d7f7f

4 files changed

Lines changed: 217 additions & 83 deletions

File tree

frontend/src/components/SearchPanel.tsx

Lines changed: 20 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,30 @@
11
import { useState } from 'react';
22
import { toast } from 'sonner';
3-
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
4-
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
5-
import { SearchBox } from './search';
3+
import { SearchBox, ResultCard } from './search';
64
import type { SearchResult } from '../types';
75

86
interface SearchPanelProps {
97
repoId: string;
108
apiUrl: string;
119
apiKey: string;
10+
repoUrl?: string;
1211
}
1312

14-
export function SearchPanel({ repoId, apiUrl, apiKey }: SearchPanelProps) {
13+
export function SearchPanel({ repoId, apiUrl, apiKey, repoUrl }: SearchPanelProps) {
1514
const [query, setQuery] = useState('');
1615
const [results, setResults] = useState<SearchResult[]>([]);
1716
const [loading, setLoading] = useState(false);
1817
const [searchTime, setSearchTime] = useState<number | null>(null);
1918
const [cached, setCached] = useState(false);
2019
const [hasSearched, setHasSearched] = useState(false);
20+
const [aiSummary, setAiSummary] = useState<string | null>(null);
2121

2222
const handleSearch = async () => {
2323
if (!query.trim()) return;
2424

2525
setLoading(true);
2626
setHasSearched(true);
27+
setAiSummary(null);
2728
const startTime = Date.now();
2829

2930
try {
@@ -44,6 +45,10 @@ export function SearchPanel({ repoId, apiUrl, apiKey }: SearchPanelProps) {
4445
setResults(data.results || []);
4546
setSearchTime(Date.now() - startTime);
4647
setCached(data.cached || false);
48+
49+
if (data.ai_summary) {
50+
setAiSummary(data.ai_summary);
51+
}
4752
} catch (error) {
4853
console.error('Search error:', error);
4954
toast.error('Search failed', {
@@ -72,8 +77,8 @@ export function SearchPanel({ repoId, apiUrl, apiKey }: SearchPanelProps) {
7277
<span className="font-semibold text-text-primary">{results.length}</span> results
7378
</span>
7479
<span className="text-text-muted"></span>
75-
<span>
76-
<span className="font-mono font-semibold text-text-primary">{searchTime}ms</span>
80+
<span className="font-mono">
81+
<span className="font-semibold text-text-primary">{searchTime}</span>ms
7782
</span>
7883
{cached && (
7984
<>
@@ -86,83 +91,16 @@ export function SearchPanel({ repoId, apiUrl, apiKey }: SearchPanelProps) {
8691
</div>
8792

8893
{/* Results */}
89-
<div className="space-y-4">
94+
<div className="space-y-3">
9095
{results.map((result, idx) => (
91-
<div
92-
key={idx}
93-
className="card p-5 hover:border-border-accent transition-all duration-normal group"
94-
>
95-
{/* Header */}
96-
<div className="flex items-start justify-between mb-4">
97-
<div className="flex-1">
98-
<div className="flex items-center gap-2 mb-1">
99-
<h3 className="font-mono font-semibold text-sm text-text-primary">
100-
{result.name}
101-
</h3>
102-
<span className="badge-neutral text-[10px] uppercase tracking-wide">
103-
{result.type.replace('_', ' ')}
104-
</span>
105-
</div>
106-
<p className="text-xs text-text-muted font-mono">
107-
{result.file_path.split('/').slice(-3).join('/')}
108-
</p>
109-
</div>
110-
111-
<div className="flex items-center gap-3">
112-
<div className="text-right">
113-
<div className="text-xs font-mono text-text-muted">Match</div>
114-
<div className="text-sm font-mono font-semibold text-accent">
115-
{(result.score * 100).toFixed(0)}%
116-
</div>
117-
</div>
118-
<button
119-
onClick={(e) => {
120-
e.stopPropagation();
121-
navigator.clipboard.writeText(result.code);
122-
toast.success('Code copied!');
123-
}}
124-
className="btn-ghost px-3 py-1.5 text-sm opacity-0 group-hover:opacity-100"
125-
title="Copy code"
126-
>
127-
Copy
128-
</button>
129-
</div>
130-
</div>
131-
132-
{/* Code */}
133-
<div className="relative rounded-lg overflow-hidden">
134-
<SyntaxHighlighter
135-
language={result.language}
136-
style={oneDark}
137-
customStyle={{
138-
margin: 0,
139-
borderRadius: '0.5rem',
140-
fontSize: '0.75rem',
141-
lineHeight: '1.5',
142-
background: 'var(--color-bg-secondary)',
143-
}}
144-
showLineNumbers
145-
startingLineNumber={result.line_start}
146-
>
147-
{result.code}
148-
</SyntaxHighlighter>
149-
150-
<div className="absolute top-3 right-3">
151-
<span className="px-2 py-0.5 text-[10px] font-mono uppercase glass text-text-muted rounded">
152-
{result.language}
153-
</span>
154-
</div>
155-
</div>
156-
157-
{/* Footer */}
158-
<div className="mt-3 flex items-center gap-3 text-xs text-text-muted">
159-
<span className="font-mono">
160-
Lines {result.line_start}{result.line_end}
161-
</span>
162-
<span></span>
163-
<span className="truncate">{result.file_path}</span>
164-
</div>
165-
</div>
96+
<ResultCard
97+
key={`${result.file_path}-${result.line_start}-${idx}`}
98+
result={result}
99+
rank={idx + 1}
100+
isExpanded={idx === 0}
101+
aiSummary={idx === 0 ? aiSummary || undefined : undefined}
102+
repoUrl={repoUrl}
103+
/>
166104
))}
167105
</div>
168106

frontend/src/components/dashboard/DashboardHome.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,8 @@ export function DashboardHome() {
216216
<SearchPanel
217217
repoId={selectedRepo}
218218
apiUrl={API_URL}
219-
apiKey={session?.access_token || ''}
219+
apiKey={session?.access_token || ''}
220+
repoUrl={selectedRepoData?.git_url?.replace('.git', '')}
220221
/>
221222
)}
222223

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { useState, useRef, useEffect } from 'react';
2+
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
3+
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
4+
import { toast } from 'sonner';
5+
import type { SearchResult } from '../../types';
6+
7+
interface ResultCardProps {
8+
result: SearchResult;
9+
rank: number;
10+
isExpanded?: boolean;
11+
aiSummary?: string;
12+
repoUrl?: string;
13+
}
14+
15+
export function ResultCard({
16+
result,
17+
rank,
18+
isExpanded: initialExpanded = false,
19+
aiSummary,
20+
repoUrl
21+
}: ResultCardProps) {
22+
const [expanded, setExpanded] = useState(initialExpanded);
23+
const contentRef = useRef<HTMLDivElement>(null);
24+
const [contentHeight, setContentHeight] = useState<number | undefined>(
25+
initialExpanded ? undefined : 0
26+
);
27+
28+
const matchPercent = Math.round(result.score * 100);
29+
const isTopResult = rank === 1;
30+
31+
// Extract clean file path (remove repos/{uuid}/ prefix if present)
32+
const cleanFilePath = result.file_path.replace(/^repos\/[a-f0-9-]+\//, '');
33+
const displayPath = cleanFilePath.split('/').slice(-3).join('/');
34+
35+
// Build GitHub URL with clean path
36+
const githubUrl = repoUrl
37+
? `${repoUrl}/blob/main/${cleanFilePath}#L${result.line_start}-L${result.line_end}`
38+
: null;
39+
40+
// Animate height on expand/collapse
41+
useEffect(() => {
42+
if (expanded) {
43+
const height = contentRef.current?.scrollHeight;
44+
setContentHeight(height);
45+
// After animation, set to auto for dynamic content
46+
const timer = setTimeout(() => setContentHeight(undefined), 200);
47+
return () => clearTimeout(timer);
48+
} else {
49+
// First set explicit height, then animate to 0
50+
const height = contentRef.current?.scrollHeight;
51+
setContentHeight(height);
52+
requestAnimationFrame(() => setContentHeight(0));
53+
}
54+
}, [expanded]);
55+
56+
const copyCode = () => {
57+
navigator.clipboard.writeText(result.code);
58+
toast.success('Copied to clipboard');
59+
};
60+
61+
return (
62+
<div
63+
className={`
64+
card overflow-hidden transition-all duration-200
65+
${expanded ? 'ring-1 ring-accent/20' : 'hover:border-border-accent'}
66+
${isTopResult ? 'border-accent/30' : ''}
67+
`}
68+
>
69+
{/* Header */}
70+
<button
71+
onClick={() => setExpanded(!expanded)}
72+
className="w-full p-4 flex items-start justify-between text-left hover:bg-white/[0.02] transition-colors"
73+
>
74+
<div className="flex-1 min-w-0">
75+
<div className="flex items-center gap-2 mb-1">
76+
{isTopResult && (
77+
<span className="badge-accent text-[10px]">TOP MATCH</span>
78+
)}
79+
<h3 className="font-mono font-semibold text-sm text-text-primary truncate">
80+
{result.name || 'anonymous'}
81+
</h3>
82+
<span className="badge-neutral text-[10px] uppercase shrink-0">
83+
{result.type.replace('_', ' ')}
84+
</span>
85+
</div>
86+
<p className="text-xs text-text-muted font-mono truncate">{displayPath}</p>
87+
</div>
88+
89+
<div className="flex items-center gap-3 ml-4 shrink-0">
90+
<div className="flex items-center gap-2">
91+
<div className="w-16 h-1.5 bg-bg-tertiary rounded-full overflow-hidden">
92+
<div
93+
className="h-full bg-gradient-to-r from-accent to-accent-light rounded-full transition-all"
94+
style={{ width: `${matchPercent}%` }}
95+
/>
96+
</div>
97+
<span className="text-sm font-mono font-semibold text-accent w-10 text-right">
98+
{matchPercent}%
99+
</span>
100+
</div>
101+
102+
<svg
103+
className={`w-4 h-4 text-text-muted transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`}
104+
fill="none"
105+
viewBox="0 0 24 24"
106+
stroke="currentColor"
107+
>
108+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
109+
</svg>
110+
</div>
111+
</button>
112+
113+
{/* Expandable content with animation */}
114+
<div
115+
ref={contentRef}
116+
className="overflow-hidden transition-all duration-200 ease-out"
117+
style={{ height: contentHeight !== undefined ? contentHeight : 'auto' }}
118+
>
119+
<div className="border-t border-border">
120+
{/* AI Summary */}
121+
{aiSummary && isTopResult && (
122+
<div className="px-4 py-3 bg-accent/5 border-b border-border">
123+
<div className="flex items-start gap-2">
124+
<span className="text-accent text-sm"></span>
125+
<div>
126+
<p className="text-xs font-medium text-accent mb-1">AI Summary</p>
127+
<p className="text-sm text-text-secondary leading-relaxed">{aiSummary}</p>
128+
</div>
129+
</div>
130+
</div>
131+
)}
132+
133+
{/* Code block */}
134+
<div className="relative">
135+
<SyntaxHighlighter
136+
language={result.language || 'text'}
137+
style={oneDark}
138+
customStyle={{
139+
margin: 0,
140+
borderRadius: 0,
141+
fontSize: '0.75rem',
142+
lineHeight: '1.6',
143+
background: 'var(--color-bg-secondary)',
144+
padding: '1rem',
145+
}}
146+
showLineNumbers
147+
startingLineNumber={result.line_start}
148+
wrapLines
149+
>
150+
{result.code}
151+
</SyntaxHighlighter>
152+
153+
<span className="absolute top-3 right-3 px-2 py-0.5 text-[10px] font-mono uppercase bg-bg-tertiary text-text-muted rounded">
154+
{result.language}
155+
</span>
156+
</div>
157+
158+
{/* Footer */}
159+
<div className="px-4 py-3 bg-bg-secondary/50 flex items-center justify-between">
160+
<span className="text-xs text-text-muted font-mono">
161+
Lines {result.line_start}{result.line_end}
162+
</span>
163+
164+
<div className="flex items-center gap-2">
165+
<button
166+
onClick={copyCode}
167+
className="btn-ghost px-3 py-1.5 text-xs flex items-center gap-1.5"
168+
>
169+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
170+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
171+
</svg>
172+
Copy
173+
</button>
174+
175+
{githubUrl && (
176+
<a
177+
href={githubUrl}
178+
target="_blank"
179+
rel="noopener noreferrer"
180+
className="btn-ghost px-3 py-1.5 text-xs flex items-center gap-1.5"
181+
>
182+
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
183+
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
184+
</svg>
185+
View
186+
</a>
187+
)}
188+
</div>
189+
</div>
190+
</div>
191+
</div>
192+
</div>
193+
);
194+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { SearchBox } from './SearchBox';
2+
export { ResultCard } from './ResultCard';

0 commit comments

Comments
 (0)