feat(TRUEREF-0016): implement web UI search explorer

- Two-step search workflow: resolve library then query documentation
- URL state sync (/search?lib=...&q=...)
- LibraryResult, SnippetCard, SearchInput components
- Code/info snippet display with breadcrumbs and token counts
- Copy-as-markdown button for full response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Giancarmine Salucci
2026-03-23 09:07:13 +01:00
parent 90d93786a8
commit 22bf4c1014
6 changed files with 473 additions and 0 deletions

View File

@@ -0,0 +1,292 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { get } from 'svelte/store';
import LibraryResult from '$lib/components/search/LibraryResult.svelte';
import SnippetCard from '$lib/components/search/SnippetCard.svelte';
import SearchInput from '$lib/components/search/SearchInput.svelte';
import { copyToClipboard } from '$lib/utils/copy-to-clipboard';
import type { LibrarySearchJsonResult, SnippetJson } from '$lib/server/api/formatters';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let libraryName = $state('');
let selectedLibraryId = $state<string | null>(null);
let selectedLibraryTitle = $state<string | null>(null);
let query = $state('');
let libraryResults = $state<LibrarySearchJsonResult[]>([]);
let snippets = $state<SnippetJson[]>([]);
let totalTokens = $state(0);
let loadingLibraries = $state(false);
let loadingSnippets = $state(false);
let libraryError = $state<string | null>(null);
let snippetError = $state<string | null>(null);
let copyLabel = $state('Copy as Markdown');
// ---------------------------------------------------------------------------
// Derived
// ---------------------------------------------------------------------------
const hasLibraryResults = $derived(libraryResults.length > 0);
const hasSnippets = $derived(snippets.length > 0);
const step = $derived<'library' | 'docs'>(selectedLibraryId ? 'docs' : 'library');
// ---------------------------------------------------------------------------
// Initialise from URL params on mount
// ---------------------------------------------------------------------------
onMount(() => {
const currentPage = get(page);
const libParam = currentPage.url.searchParams.get('lib');
const qParam = currentPage.url.searchParams.get('q');
if (libParam) {
selectedLibraryId = libParam;
// Try to restore library name from id for display.
selectedLibraryTitle = libParam;
}
if (qParam) {
query = qParam;
}
if (libParam && qParam) {
searchDocs();
}
});
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
async function searchLibraries() {
if (!libraryName.trim()) return;
loadingLibraries = true;
libraryError = null;
libraryResults = [];
try {
const res = await fetch(
`/api/v1/libs/search?libraryName=${encodeURIComponent(libraryName)}&query=${encodeURIComponent(query)}`
);
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? `Request failed (${res.status})`);
}
const data = await res.json();
libraryResults = data.results ?? [];
} catch (e) {
libraryError = (e as Error).message;
} finally {
loadingLibraries = false;
}
}
async function searchDocs() {
if (!selectedLibraryId || !query.trim()) return;
loadingSnippets = true;
snippetError = null;
snippets = [];
totalTokens = 0;
try {
const url = new URL('/api/v1/context', window.location.origin);
url.searchParams.set('libraryId', selectedLibraryId);
url.searchParams.set('query', query);
const res = await fetch(url);
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? `Request failed (${res.status})`);
}
const data = await res.json();
snippets = data.snippets ?? [];
totalTokens = data.totalTokens ?? 0;
// Sync URL state.
goto(
`/search?lib=${encodeURIComponent(selectedLibraryId)}&q=${encodeURIComponent(query)}`,
{ replaceState: true, keepFocus: true }
);
} catch (e) {
snippetError = (e as Error).message;
} finally {
loadingSnippets = false;
}
}
function selectLibrary(id: string) {
const found = libraryResults.find((r) => r.id === id);
selectedLibraryId = id;
selectedLibraryTitle = found?.title ?? id;
libraryResults = [];
libraryName = '';
}
function changeLibrary() {
selectedLibraryId = null;
selectedLibraryTitle = null;
snippets = [];
totalTokens = 0;
snippetError = null;
query = '';
// Reset URL.
goto('/search', { replaceState: true, keepFocus: true });
}
// ---------------------------------------------------------------------------
// Copy as Markdown
// ---------------------------------------------------------------------------
function buildMarkdown(): string {
const parts: string[] = [];
for (const snippet of snippets) {
if (snippet.type === 'code') {
const lang = snippet.codeList[0]?.language ?? '';
const code = snippet.codeList[0]?.code ?? '';
if (snippet.title) parts.push(`### ${snippet.title}`);
if (snippet.description) parts.push(`*${snippet.description}*`);
parts.push(`\`\`\`${lang}\n${code}\n\`\`\``);
} else {
if (snippet.breadcrumb) parts.push(`*${snippet.breadcrumb}*`);
parts.push(snippet.text);
}
parts.push('---');
}
if (parts.at(-1) === '---') parts.pop();
return parts.join('\n\n');
}
async function handleCopy() {
const markdown = buildMarkdown();
const ok = await copyToClipboard(markdown);
copyLabel = ok ? 'Copied!' : 'Failed to copy';
setTimeout(() => {
copyLabel = 'Copy as Markdown';
}, 2000);
}
</script>
<svelte:head>
<title>Search — TrueRef</title>
</svelte:head>
<div class="space-y-6">
<div>
<h1 class="text-xl font-semibold text-gray-900">Search Documentation</h1>
<p class="mt-0.5 text-sm text-gray-500">
Find libraries and query their indexed documentation.
</p>
</div>
{#if step === 'library'}
<!-- ------------------------------------------------------------------ -->
<!-- Step 1: Library search -->
<!-- ------------------------------------------------------------------ -->
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="mb-1 text-sm font-semibold text-gray-700">Step 1 — Select a library</h2>
<p class="mb-4 text-xs text-gray-500">
Enter a library name to find matching indexed repositories.
</p>
<SearchInput
bind:value={libraryName}
placeholder="Library name, e.g. react, tailwind..."
onsubmit={searchLibraries}
loading={loadingLibraries}
/>
</div>
{#if libraryError}
<div class="rounded-lg bg-red-50 px-4 py-3">
<p class="text-sm text-red-700">{libraryError}</p>
</div>
{/if}
{#if loadingLibraries}
<div class="flex items-center justify-center py-12 text-sm text-gray-400">
Searching libraries...
</div>
{:else if hasLibraryResults}
<div class="space-y-3">
{#each libraryResults as result (result.id)}
<LibraryResult {result} onSelect={selectLibrary} />
{/each}
</div>
{:else if libraryName && !loadingLibraries && libraryResults.length === 0 && !libraryError}
<!-- Only show empty state after a search was executed -->
{/if}
{:else}
<!-- ------------------------------------------------------------------ -->
<!-- Step 2: Documentation query -->
<!-- ------------------------------------------------------------------ -->
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="mb-4 flex items-center gap-3">
<div class="flex min-w-0 flex-1 items-center gap-2">
<span class="shrink-0 rounded bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
Selected
</span>
<span class="truncate font-mono text-sm text-gray-700">{selectedLibraryTitle}</span>
</div>
<button
onclick={changeLibrary}
class="shrink-0 rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-600 transition-colors hover:bg-gray-50"
>
Change
</button>
</div>
<h2 class="mb-1 text-sm font-semibold text-gray-700">Step 2 — Query documentation</h2>
<p class="mb-4 text-xs text-gray-500">Ask a question about this library.</p>
<SearchInput
bind:value={query}
placeholder="What would you like to know?"
onsubmit={searchDocs}
loading={loadingSnippets}
/>
</div>
{#if snippetError}
<div class="rounded-lg bg-red-50 px-4 py-3">
<p class="text-sm text-red-700">{snippetError}</p>
</div>
{/if}
{#if loadingSnippets}
<div class="flex items-center justify-center py-12 text-sm text-gray-400">
Fetching documentation snippets...
</div>
{:else if hasSnippets}
<!-- Summary bar -->
<div class="flex items-center justify-between">
<p class="text-sm text-gray-500">
{snippets.length} snippet{snippets.length === 1 ? '' : 's'} &middot;
<span class="font-medium text-gray-700">{totalTokens.toLocaleString()} tokens</span>
</p>
<button
onclick={handleCopy}
class="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-600 transition-colors hover:bg-gray-50"
>
{copyLabel}
</button>
</div>
<!-- Snippet list -->
<div class="space-y-4">
{#each snippets as snippet, i (i)}
<SnippetCard {snippet} />
{/each}
</div>
{:else if query && !loadingSnippets && snippets.length === 0 && !snippetError}
<div class="flex flex-col items-center py-16 text-center">
<p class="text-sm text-gray-500">No snippets found for that query.</p>
<p class="mt-1 text-xs text-gray-400">Try a different question or select another library.</p>
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1 @@
export const ssr = false;