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,33 @@
<script lang="ts">
let {
result,
onSelect
}: {
result: {
id: string;
title: string;
description: string | null;
totalSnippets: number | null;
trustScore: number | null;
};
onSelect: (id: string) => void;
} = $props();
const snippetCount = $derived(result.totalSnippets ?? 0);
const trust = $derived(result.trustScore ?? 0);
</script>
<button
onclick={() => onSelect(result.id)}
class="w-full rounded-xl border border-gray-200 bg-white p-4 text-left shadow-sm transition-all hover:border-blue-300 hover:shadow-md"
>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-900">{result.title}</span>
<span class="text-xs text-gray-400">Trust {trust.toFixed(1)}/10</span>
</div>
<p class="font-mono text-xs text-gray-400">{result.id}</p>
{#if result.description}
<p class="mt-1.5 line-clamp-2 text-sm text-gray-600">{result.description}</p>
{/if}
<p class="mt-2 text-xs text-gray-400">{snippetCount.toLocaleString()} snippets</p>
</button>

View File

@@ -0,0 +1,56 @@
<script lang="ts">
let {
value = $bindable(''),
placeholder = 'Search...',
onsubmit,
loading = false
}: {
value?: string;
placeholder?: string;
onsubmit: () => void;
loading?: boolean;
} = $props();
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
onsubmit();
}
}
</script>
<div class="flex gap-2">
<input
type="text"
bind:value
{placeholder}
onkeydown={handleKeydown}
disabled={loading}
class="flex-1 rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm outline-none transition-all focus:border-blue-400 focus:ring-2 focus:ring-blue-100 disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
onclick={onsubmit}
disabled={loading || !value.trim()}
class="rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if loading}
<span class="inline-flex items-center gap-1.5">
<svg
class="h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Searching
</span>
{:else}
Search
{/if}
</button>
</div>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import type { SnippetJson } from '$lib/server/api/formatters';
let { snippet }: { snippet: SnippetJson } = $props();
const isCode = $derived(snippet.type === 'code');
const title = $derived(
snippet.type === 'code' ? snippet.title : null
);
const breadcrumb = $derived(
snippet.type === 'code' ? snippet.description : snippet.breadcrumb
);
const content = $derived(
snippet.type === 'code' ? snippet.codeList[0]?.code ?? '' : snippet.text
);
const language = $derived(
snippet.type === 'code' ? (snippet.codeList[0]?.language ?? '') : null
);
const tokenCount = $derived(snippet.tokenCount ?? 0);
</script>
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-2.5">
<div class="flex items-center gap-2">
{#if isCode}
<span class="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-700">code</span>
{:else}
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">info</span>
{/if}
{#if title}
<span class="text-sm font-medium text-gray-800">{title}</span>
{/if}
</div>
<span class="text-xs text-gray-400">{tokenCount} tokens</span>
</div>
{#if breadcrumb}
<p class="bg-gray-50 px-4 py-1.5 text-xs italic text-gray-500">{breadcrumb}</p>
{/if}
<div class="p-4">
{#if isCode}
<pre
class="overflow-x-auto rounded bg-gray-950 p-4 text-sm text-gray-100"
><code>{content}</code></pre>
{:else}
<div class="prose prose-sm max-w-none whitespace-pre-wrap text-gray-700">{content}</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,36 @@
/**
* Copy text to the system clipboard.
*
* Falls back gracefully when the Clipboard API is unavailable (e.g. non-HTTPS
* or older browsers) by using a temporary textarea element.
*
* @returns true when the copy succeeded, false otherwise.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
if (typeof navigator === 'undefined') return false;
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fall through to legacy path.
}
}
// Legacy fallback via execCommand.
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}

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;