qmd-web / src /components /SearchColumn.tsx
shreyask's picture
Deploy qmd-web
ac50275 verified
import { useState } from 'react';
import type { ScoredChunk } from '../types';
import { InfoTooltip } from './PipelineView';
interface SearchColumnState {
status: 'idle' | 'running' | 'done';
data?: { bm25Hits: ScoredChunk[]; vectorHits: ScoredChunk[] };
}
interface SearchColumnProps {
state: SearchColumnState;
accent: string;
info: string;
}
function Spinner({ color }: { color: string }) {
return (
<span style={{
display: 'inline-block',
width: '14px',
height: '14px',
border: '2px solid var(--border)',
borderTopColor: color,
borderRadius: '50%',
animation: 'spin 0.7s linear infinite',
}} />
);
}
function ScoreBadge({ score, source }: { score: number; source: 'bm25' | 'vector' }) {
const label = source === 'bm25'
? score.toFixed(2)
: (score * 100).toFixed(1) + '%';
return (
<span style={{
padding: '0.1rem 0.35rem',
borderRadius: '4px',
background: 'var(--bg-card)',
border: '1px solid var(--border)',
color: 'var(--text-secondary)',
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
fontSize: '0.65rem',
fontWeight: 700,
flexShrink: 0,
}}>
{label}
</span>
);
}
function HitRow({ hit }: { hit: ScoredChunk }) {
const [open, setOpen] = useState(false);
return (
<div
onClick={() => setOpen(o => !o)}
style={{
padding: '0.4rem 0.6rem',
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: '5px',
marginBottom: '0.25rem',
cursor: 'pointer',
fontSize: '0.75rem',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = '0 1px 5px var(--shadow)'; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = 'none'; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<span style={{
flex: 1,
fontFamily: 'system-ui, -apple-system, sans-serif',
fontWeight: 600,
color: 'var(--text)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '0.73rem',
}}>
{hit.chunk.title}
</span>
<ScoreBadge score={hit.score} source={hit.source} />
<span style={{ color: 'var(--text-muted)', fontSize: '0.6rem' }}>{open ? '\u25B2' : '\u25BC'}</span>
</div>
{open && (
<div style={{
marginTop: '0.35rem',
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
fontSize: '0.65rem',
color: 'var(--text-secondary)',
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
borderTop: '1px solid var(--border-light)',
paddingTop: '0.35rem',
}}>
{hit.chunk.text}
</div>
)}
</div>
);
}
// Deduplicate hits by docId, keeping the highest score per document.
function dedupeByDoc(hits: ScoredChunk[]): ScoredChunk[] {
const best = new Map<string, ScoredChunk>();
for (const hit of hits) {
const existing = best.get(hit.chunk.docId);
if (!existing || hit.score > existing.score) {
best.set(hit.chunk.docId, hit);
}
}
return [...best.values()].sort((a, b) => b.score - a.score);
}
function HitsSection({ label, hits, color, dedupe }: { label: string; hits: ScoredChunk[]; color: string; dedupe?: boolean }) {
const displayHits = dedupe ? dedupeByDoc(hits) : hits;
const top = displayHits.slice(0, 5);
return (
<div style={{ marginBottom: '0.7rem' }}>
<div style={{
fontSize: '0.68rem',
fontWeight: 700,
fontFamily: 'system-ui, -apple-system, sans-serif',
color,
textTransform: 'uppercase',
letterSpacing: '0.06em',
marginBottom: '0.35rem',
}}>
{label} <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>({displayHits.length} docs)</span>
</div>
{top.map((hit, i) => (
<HitRow key={`${hit.chunk.docId}-${hit.chunk.chunkIndex}-${i}`} hit={hit} />
))}
{displayHits.length > 5 && (
<div style={{
fontSize: '0.68rem',
color: 'var(--text-muted)',
fontFamily: 'system-ui, -apple-system, sans-serif',
paddingLeft: '0.25rem',
}}>
+{displayHits.length - 5} more
</div>
)}
</div>
);
}
export default function SearchColumn({ state, accent, info }: SearchColumnProps) {
const isIdle = state.status === 'idle';
const isRunning = state.status === 'running';
const isDone = state.status === 'done';
return (
<div style={{ opacity: isIdle ? 0.45 : 1, transition: 'opacity 0.3s' }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
marginBottom: '0.75rem',
paddingBottom: '0.5rem',
borderBottom: '1px solid var(--stage-divider)',
}}>
<span style={{
width: '3px',
height: '14px',
borderRadius: '2px',
background: accent,
flexShrink: 0,
}} />
<h3 style={{
margin: 0,
fontSize: '0.78rem',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontWeight: 700,
color: accent,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
Parallel Search
</h3>
{isRunning && <Spinner color={accent} />}
<InfoTooltip text={info} />
</div>
{isIdle && (
<p style={{ fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.75rem', color: 'var(--text-muted)', margin: 0 }}>
Awaiting expansion...
</p>
)}
{isRunning && (
<p style={{ fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.75rem', color: 'var(--text-secondary)', margin: 0, fontStyle: 'italic' }}>
Running vector + BM25 search...
</p>
)}
{isDone && state.data && (
<>
<HitsSection
label="Vector Search"
hits={state.data.vectorHits}
color="#00695c"
dedupe
/>
<HitsSection
label="BM25 Search"
hits={state.data.bm25Hits}
color="#5c6bc0"
dedupe
/>
</>
)}
</div>
);
}