# Model Search Browser

Model Search Browser [#model-search-browser]

The **Model Search Browser** is a cmdk-based searchable model-repo browser: a controlled search input, a sort selector (downloads / likes / last-modified), result rows (repo id, author, compact download/like counts, relative last-modified, capped tag badges), load-more pagination, and a per-repo expandable file list (filename, quantization badge, human-readable size) with per-file select actions — plus distinct loading, empty, and error-with-retry states.

It is purely presentational: every value arrives via props and every action leaves via a callback, so **any backend can feed it** — the HuggingFace Hub API, a private model registry, or static fixtures. The consumer owns all fetching, debouncing, and pagination state; cmdk's built-in filtering is disabled because results arrive pre-filtered from your backend.

**When to use it:** browsing a large remote model catalog (e.g. the 160K+ GGUF repos on HuggingFace) down to a concrete file the user can load.

Preview [#preview]

```tsx
'use client';

import { useState } from 'react';

import {
  ModelSearchBrowser,
  type ModelRepoFile,
  type ModelSearchResult,
  type ModelSearchSort,
} from '@/components/model-search-browser';

const REPOS: ModelSearchResult[] = [
  {
    repoId: 'bartowski/Llama-3.2-1B-Instruct-GGUF',
    author: 'bartowski',
    downloads: 1_243_500,
    likes: 412,
    lastModified: '2026-05-18T09:30:00Z',
    tags: ['llama', 'text-generation', 'conversational', 'gguf', 'en'],
  },
  {
    repoId: 'TheBloke/Mistral-7B-Instruct-v0.2-GGUF',
    author: 'TheBloke',
    downloads: 2_051_000,
    likes: 934,
    lastModified: '2025-12-05T11:20:00Z',
    tags: ['mistral', 'text-generation', 'conversational', 'gguf', 'en', 'instruct'],
  },
  {
    repoId: 'Qwen/Qwen2.5-0.5B-Instruct-GGUF',
    author: 'Qwen',
    downloads: 887_200,
    likes: 265,
    lastModified: '2026-06-02T14:00:00Z',
    tags: ['qwen2', 'text-generation', 'gguf', 'chat'],
  },
  {
    repoId: 'unsloth/gemma-3-4b-it-GGUF',
    author: 'unsloth',
    downloads: 512_400,
    likes: 198,
    lastModified: '2026-06-20T08:15:00Z',
    tags: ['gemma3', 'vision', 'text-generation', 'gguf', 'multimodal'],
  },
  {
    repoId: 'nomic-ai/nomic-embed-text-v1.5-GGUF',
    author: 'nomic-ai',
    downloads: 405_900,
    likes: 151,
    lastModified: '2026-03-11T17:45:00Z',
    tags: ['embeddings', 'sentence-similarity', 'gguf'],
  },
  {
    repoId: 'ggml-org/SmolLM3-3B-GGUF',
    author: 'ggml-org',
    downloads: 96_400,
    likes: 74,
    lastModified: '2026-06-27T10:05:00Z',
    tags: ['smollm3', 'text-generation', 'gguf'],
  },
];

const FILES: Record<string, ModelRepoFile[]> = {
  'bartowski/Llama-3.2-1B-Instruct-GGUF': [
    { filename: 'Llama-3.2-1B-Instruct-Q4_K_M.gguf', sizeBytes: 807_690_000, quantLabel: 'Q4_K_M' },
    { filename: 'Llama-3.2-1B-Instruct-Q6_K.gguf', sizeBytes: 1_021_800_000, quantLabel: 'Q6_K' },
    { filename: 'Llama-3.2-1B-Instruct-Q8_0.gguf', sizeBytes: 1_321_100_000, quantLabel: 'Q8_0' },
  ],
  'TheBloke/Mistral-7B-Instruct-v0.2-GGUF': [
    { filename: 'mistral-7b-instruct-v0.2.Q4_K_M.gguf', sizeBytes: 4_368_400_000, quantLabel: 'Q4_K_M' },
    { filename: 'mistral-7b-instruct-v0.2.Q5_K_M.gguf', sizeBytes: 5_131_400_000, quantLabel: 'Q5_K_M' },
  ],
  'Qwen/Qwen2.5-0.5B-Instruct-GGUF': [
    { filename: 'qwen2.5-0.5b-instruct-q4_k_m.gguf', sizeBytes: 397_800_000, quantLabel: 'Q4_K_M' },
    { filename: 'qwen2.5-0.5b-instruct-q8_0.gguf', sizeBytes: 531_100_000, quantLabel: 'Q8_0' },
  ],
  'unsloth/gemma-3-4b-it-GGUF': [
    { filename: 'gemma-3-4b-it-Q4_K_M.gguf', sizeBytes: 2_489_900_000, quantLabel: 'Q4_K_M' },
    { filename: 'gemma-3-4b-it-Q6_K.gguf', sizeBytes: 3_190_500_000, quantLabel: 'Q6_K' },
  ],
  'nomic-ai/nomic-embed-text-v1.5-GGUF': [
    { filename: 'nomic-embed-text-v1.5.Q4_K_M.gguf', sizeBytes: 84_100_000, quantLabel: 'Q4_K_M' },
    { filename: 'nomic-embed-text-v1.5.f16.gguf', sizeBytes: 274_300_000, quantLabel: 'F16' },
  ],
  'ggml-org/SmolLM3-3B-GGUF': [
    { filename: 'SmolLM3-3B-Q4_K_M.gguf', sizeBytes: 1_921_600_000, quantLabel: 'Q4_K_M' },
    { filename: 'SmolLM3-3B-Q8_0.gguf', sizeBytes: 3_285_400_000, quantLabel: 'Q8_0' },
  ],
};

/** Rows revealed initially; "Load more" reveals the rest. */
const PAGE_SIZE = 4;

/** Sort comparator over the fixture repos for the active sort order. */
function compareBySort(a: ModelSearchResult, b: ModelSearchResult, sort: ModelSearchSort) {
  if (sort === 'lastModified') return (b.lastModified ?? '').localeCompare(a.lastModified ?? '');
  return (b[sort] ?? 0) - (a[sort] ?? 0);
}

/**
 * Demo for ModelSearchBrowser, used by the docs live preview. Searches, sorts,
 * paginates, and expands a static fixture catalog of GGUF repos entirely in
 * local state — zero network requests and no model download on mount.
 */
export default function ModelSearchBrowserDemo() {
  const [query, setQuery] = useState('');
  const [sort, setSort] = useState<ModelSearchSort>('downloads');
  const [expanded, setExpanded] = useState<string | null>(null);
  const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
  const [picked, setPicked] = useState<string | null>(null);

  const filtered = REPOS.filter((r) =>
    r.repoId.toLowerCase().includes(query.trim().toLowerCase()),
  ).sort((a, b) => compareBySort(a, b, sort));
  const visible = filtered.slice(0, visibleCount);

  return (
    <div className="flex w-full flex-col items-center gap-3">
      <ModelSearchBrowser
        query={query}
        onQueryChange={(q) => {
          setQuery(q);
          setVisibleCount(PAGE_SIZE);
          setExpanded(null);
        }}
        sort={sort}
        onSortChange={setSort}
        results={visible}
        hasMore={filtered.length > visibleCount}
        onLoadMore={() => setVisibleCount(filtered.length)}
        expandedRepoId={expanded}
        onSelectRepo={setExpanded}
        files={expanded ? (FILES[expanded] ?? []) : null}
        onSelectFile={(repoId, file) => setPicked(`${repoId}:${file.filename}`)}
      />
      {picked && (
        <p className="w-full max-w-2xl truncate text-xs text-muted-foreground">
          Selected: <span className="font-mono">{picked}</span>
        </p>
      )}
    </div>
  );
}
```

Installation [#installation]

```bash
npx shadcn@latest add @localmode/ui/local-first/model-search-browser
```

Data source & dependencies [#data-source--dependencies]

**Data source:** renders the `results` and `files` you pass and emits every action via callbacks — works with any backend. Recommended wiring: the HuggingFace Hub API (see the example below); a private registry endpoint or a static catalog works identically (optional).

* `cmdk` — the underlying command/list primitive
* `lucide-react` — icons
* `clsx` + `tailwind-merge` — via the shared `cn()` util (installed automatically as a registry dependency)

Files installed [#files-installed]

* `model-search-browser.tsx` — the `ModelSearchBrowser` component
* `lib/utils.ts` — the `cn()` helper (if not already present)

Props [#props]

**ModelSearchBrowser**

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `query` | `string` | — | **Required.** The controlled search query. |
| `onQueryChange` | `function` | — | **Required.** Fired with the new query on every keystroke. |
| `sort` | `ModelSearchSort` | — | **Required.** The active sort order. |
| `onSortChange` | `function` | — | **Required.** Fired with the chosen sort order. |
| `results` | `array` | — | **Required.** The result rows to render (already searched/sorted by the backend). |
| `isLoading` | `boolean` | — | Whether a search (or the next page) is in flight — renders skeleton rows. |
| `error` | `string \| null` | — | Error message for the failed search — renders the error state. |
| `onRetry` | `function` | — | Fired by the error state's retry affordance. |
| `hasMore` | `boolean` | — | Whether more pages exist — renders the load-more affordance. |
| `onLoadMore` | `function` | — | Fired when the user asks for the next page. |
| `expandedRepoId` | `string \| null` | — | The repo id whose file list is expanded (null/undefined: none). |
| `onSelectRepo` | `function` | — | Fired with the clicked repo id, or null when the expanded row is collapsed. |
| `files` | `array \| null` | — | Files of the expanded repo (null while not yet available). |
| `filesLoading` | `boolean` | — | Whether the expanded repo's file list is loading. |
| `filesError` | `string \| null` | — | Error message for the failed file listing. |
| `onSelectFile` | `function` | — | Fired when the user picks a file inside the expanded repo. |

**ModelSearchResult**

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `repoId` | `string` | — | **Required.** Stable repo id (e.g. "bartowski/Llama-3.2-1B-Instruct-GGUF"). |
| `author` | `string` | — | Repo author/organization. |
| `downloads` | `number` | — | Total download count (rendered compactly, e.g. "1.2M"). |
| `likes` | `number` | — | Like count (rendered compactly). |
| `lastModified` | `string` | — | Last-modified ISO timestamp (rendered as a relative time). |
| `tags` | `array` | — | Repo tags (capped with an overflow count). |

**ModelRepoFile**

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `filename` | `string` | — | **Required.** File name (e.g. "Llama-3.2-1B-Instruct-Q4_K_M.gguf"). |
| `sizeBytes` | `number` | — | File size in bytes (rendered human-readable). |
| `quantLabel` | `string` | — | Quantization label badge (e.g. "Q4_K_M"). |

Examples [#examples]

Fixture-driven (any backend) [#fixture-driven-any-backend]

The component owns no fetching — hand it rows from anywhere:

```tsx
<ModelSearchBrowser
  query={query}
  onQueryChange={setQuery}
  sort={sort}
  onSortChange={setSort}
  results={results}
  expandedRepoId={expanded}
  onSelectRepo={setExpanded}
  files={files}
  onSelectFile={(repoId, file) => pick(`${repoId}:${file.filename}`)}
/>
```

Wiring to the HuggingFace API [#wiring-to-the-huggingface-api]

The component doesn't care where results come from — this recipe feeds it from the public HuggingFace Hub API, but swapping the two `fetch` calls for your own registry endpoint changes nothing else:

```tsx
import { useEffect, useState } from 'react';
import {
  ModelSearchBrowser,
  type ModelRepoFile,
  type ModelSearchResult,
  type ModelSearchSort,
} from '@/components/model-search-browser';

export function GGUFBrowser() {
  const [query, setQuery] = useState('');
  const [sort, setSort] = useState<ModelSearchSort>('downloads');
  const [results, setResults] = useState<ModelSearchResult[]>([]);
  const [isLoading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [expanded, setExpanded] = useState<string | null>(null);
  const [files, setFiles] = useState<ModelRepoFile[] | null>(null);

  // Debounced, abortable search — the consumer owns all orchestration.
  useEffect(() => {
    const controller = new AbortController();
    const timer = setTimeout(async () => {
      setLoading(true);
      setError(null);
      try {
        const url = `https://huggingface.co/api/models?filter=gguf&search=${encodeURIComponent(query)}&sort=${sort}&direction=-1&limit=30`;
        const res = await fetch(url, { signal: controller.signal });
        if (!res.ok) throw new Error(`Search failed (HTTP ${res.status})`);
        const repos: {
          id: string;
          downloads?: number;
          likes?: number;
          lastModified?: string;
          tags?: string[];
        }[] = await res.json();
        setResults(
          repos.map((r) => ({
            repoId: r.id,
            author: r.id.split('/')[0],
            downloads: r.downloads,
            likes: r.likes,
            lastModified: r.lastModified,
            tags: r.tags,
          })),
        );
      } catch (e) {
        if (!controller.signal.aborted)
          setError(e instanceof Error ? e.message : 'Search failed');
      } finally {
        if (!controller.signal.aborted) setLoading(false);
      }
    }, 300);
    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [query, sort]);

  const selectRepo = async (repoId: string | null) => {
    setExpanded(repoId);
    setFiles(null);
    if (!repoId) return;
    const res = await fetch(`https://huggingface.co/api/models/${repoId}?blobs=true`);
    const repo: { siblings?: { rfilename: string; size?: number }[] } = await res.json();
    setFiles(
      (repo.siblings ?? [])
        .filter((s) => s.rfilename.endsWith('.gguf'))
        .map((s) => ({
          filename: s.rfilename,
          sizeBytes: s.size,
          quantLabel: s.rfilename.match(/(?:I?Q\d|F16|F32|BF16)[\w]*/i)?.[0]?.toUpperCase(),
        })),
    );
  };

  return (
    <ModelSearchBrowser
      query={query}
      onQueryChange={setQuery}
      sort={sort}
      onSortChange={setSort}
      results={results}
      isLoading={isLoading}
      error={error}
      onRetry={() => setQuery((q) => q.trim())}
      expandedRepoId={expanded}
      onSelectRepo={selectRepo}
      files={files}
      filesLoading={expanded !== null && files === null}
      onSelectFile={(repoId, file) => console.log(`load ${repoId}:${file.filename}`)}
    />
  );
}
```

Error state with retry [#error-state-with-retry]

Pass the failure message and a retry callback — the component renders the error panel and the retry affordance:

```tsx
<ModelSearchBrowser
  query={query}
  onQueryChange={setQuery}
  sort={sort}
  onSortChange={setSort}
  results={[]}
  error="Rate limited by the search API — try again in a minute."
  onRetry={refetch}
/>
```

Customization [#customization]

Result rows cap tags at 4 before collapsing into a `+N` overflow chip — change `MAX_TAGS` in the copied file. The sort labels live in the `SORT_OPTIONS` array; the compact-count, byte-size, and relative-time formatters (`formatCount`, `formatBytes`, `formatRelative`) are plain local functions you can swap for `Intl` variants. The list height is the `max-h-96` on `Command.List`. Styled entirely with shadcn/ui CSS-variable utilities so it inherits your theme — because you own the copied file, every class and threshold is yours to change.