SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
11.7 KB · 231 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { Bot, Calendar, FolderKanban, Folder as FolderIcon, Layers, Plus, Search, ShieldCheck, Tag, UserRound, X } from "lucide-react";4import { useApp, AUTO_MODEL_KEY } from "@/components/app/store";5import { useApi } from "@/lib/client/api";6import type { Project, FilterKey, FilterToken } from "@/lib/client/types";7import { addFilter, parseQuery, removeFilter } from "@/lib/search/query";8import { ResponsiveDialog } from "@/components/ui/sheet";9import { ProviderIcon } from "@/components/brand/provider-icon";10import { providerName } from "@/lib/client/providers";11import { Button } from "@/components/ui/button";12import { useIsMobile } from "@/lib/client/hooks";13import { cn } from "@/lib/utils";1415type Picker = "model" | "provider" | "project" | "folder" | "date" | "role" | "is";1617const PICKERS: { key: Picker; label: string; icon: React.ReactNode }[] = [18  { key: "model", label: "Model", icon: <Bot /> },19  { key: "project", label: "Project", icon: <FolderKanban /> },20  { key: "date", label: "Date", icon: <Calendar /> },21  { key: "role", label: "Role", icon: <UserRound /> },22  { key: "is", label: "Status", icon: <Tag /> },23  { key: "provider", label: "Provider", icon: <ShieldCheck /> },24  { key: "folder", label: "Folder", icon: <FolderIcon /> },25];2627/**28 * Filter chips for the search query: active tokens (removable) + "add filter" chips that open a small sheet29 * (ResponsiveDialog) and append tokens through `addFilter()` — the input stays the single source of truth.30 */31export function FilterChips({ query, onChange, className, compact }: { query: string; onChange: (next: string) => void; className?: string; compact?: boolean }) {32  const [picker, setPicker] = React.useState<Picker | null>(null);33  const parsed = React.useMemo(() => parseQuery(query), [query]);34  const activeKeys = new Set(parsed.tokens.map((t) => (t.key === "after" || t.key === "before" ? "date" : t.key)));3536  const apply = (key: FilterKey, value: string) => onChange(addFilter(query, key, value) + " ");37  const applyMany = (pairs: [FilterKey, string][]) => {38    let next = query;39    for (const [k, v] of pairs) next = addFilter(next, k, v);40    onChange(next + " ");41  };4243  return (44    <>45      <div className={cn("flex items-center gap-1.5 overflow-x-auto scrollbar-none", className)} role="group" aria-label="Search filters">46        {parsed.tokens.map((t) => (47          <TokenChip key={t.raw + t.key} token={t} onRemove={() => onChange(removeFilter(query, t))} compact={compact} />48        ))}49        {PICKERS.filter((p) => !compact || !activeKeys.has(p.key) || p.key === "model" || p.key === "is").map((p) => (50          <button51            key={p.key}52            type="button"53            onClick={() => setPicker(p.key)}54            className={cn("inline-flex shrink-0 items-center gap-1 rounded-full border border-dashed border-border-strong/70 font-medium text-fg-muted transition-colors hover:border-fg hover:text-fg [&_svg]:size-3.5", compact ? "h-7 px-2.5 text-[12px]" : "h-8 px-3 text-[13px]")}55          >56            {activeKeys.has(p.key) ? <Plus className="size-3" /> : p.icon}57            {p.label}58          </button>59        ))}60      </div>61      <FilterPicker picker={picker} onClose={() => setPicker(null)} onPick={apply} onPickMany={applyMany} />62    </>63  );64}6566function TokenChip({ token, onRemove, compact }: { token: FilterToken; onRemove: () => void; compact?: boolean }) {67  return (68    <span className={cn("inline-flex shrink-0 items-center gap-1 rounded-full bg-fg pl-2.5 pr-1 font-medium text-bg", compact ? "h-7 text-[12px]" : "h-8 text-[13px]")}>69      <span className="opacity-70">{token.key}:</span>70      <span className="max-w-[140px] truncate">{token.value}</span>71      <button type="button" onClick={onRemove} className="tap ml-0.5 rounded-full p-0.5 hover:bg-bg/20" aria-label={`Remove ${token.key} filter`}>72        <X className="size-3" />73      </button>74    </span>75  );76}7778/* ------------------------------------------------------------------------------------------------79 * Picker sheets80 * ---------------------------------------------------------------------------------------------- */81interface Option {82  value: string;83  label: React.ReactNode;84  hint?: React.ReactNode;85  icon?: React.ReactNode;86}8788function FilterPicker({ picker, onClose, onPick, onPickMany }: { picker: Picker | null; onClose: () => void; onPick: (key: FilterKey, value: string) => void; onPickMany: (pairs: [FilterKey, string][]) => void }) {89  const { models, connectedProviders, favorites, labels, folders } = useApp();90  const projectsQ = useApi<{ projects: Project[] }>(picker === "project" ? "/api/projects" : null, { shouldRetryOnError: false });91  const [q, setQ] = React.useState("");92  const isMobile = useIsMobile();93  const [after, setAfter] = React.useState("");94  const [before, setBefore] = React.useState("");9596  const close = () => {97    setQ("");98    setAfter("");99    setBefore("");100    onClose();101  };102  const pick = (key: FilterKey, value: string) => {103    onPick(key, value);104    close();105  };106107  const title = PICKERS.find((p) => p.key === picker)?.label ?? "Filter";108  let options: Option[] = [];109  let key: FilterKey = "model";110  let searchable = false;111112  if (picker === "model") {113    key = "model";114    searchable = true;115    const usable = models.filter((m) => connectedProviders.has(m.provider) && m.status !== "deprecated");116    const sorted = [...usable].sort((a, b) => Number(favorites.has(b.key)) - Number(favorites.has(a.key)) || a.displayName.localeCompare(b.displayName));117    options = sorted118      .filter((m) => !q || `${m.displayName} ${m.key} ${labels[m.key] ?? ""}`.toLowerCase().includes(q.toLowerCase()))119      .slice(0, 60)120      .map((m) => ({ value: m.id, label: labels[m.key] ?? m.displayName, hint: providerName(m.provider), icon: <ProviderIcon provider={m.provider} size={16} /> }));121  } else if (picker === "provider") {122    key = "provider";123    options = Array.from(connectedProviders).map((p) => ({ value: p, label: providerName(p), icon: <ProviderIcon provider={p} size={16} /> }));124  } else if (picker === "project") {125    key = "project";126    searchable = true;127    const list = (projectsQ.data?.projects ?? []).filter((p) => !p.archived);128    options = list.filter((p) => !q || p.name.toLowerCase().includes(q.toLowerCase())).map((p) => ({ value: p.name, label: p.name, hint: p.description ?? undefined, icon: p.icon ? <span className="text-[15px]">{p.icon}</span> : <FolderKanban /> }));129  } else if (picker === "folder") {130    key = "folder";131    options = folders.map((f) => ({ value: f.name, label: f.name, icon: <FolderIcon /> }));132  } else if (picker === "role") {133    key = "role";134    options = [135      { value: "user", label: "You", hint: "Messages you wrote", icon: <UserRound /> },136      { value: "assistant", label: "Assistant", hint: "Model replies", icon: <Bot /> },137    ];138  } else if (picker === "is") {139    key = "is";140    options = [141      { value: "pinned", label: "Pinned", icon: <Tag /> },142      { value: "archived", label: "Archived", icon: <Layers /> },143      { value: "shared", label: "Shared publicly", icon: <ShieldCheck /> },144    ];145  }146147  const dateOptions: { label: string; pairs: [FilterKey, string][] }[] = [148    { label: "Today", pairs: [["after", "today"]] },149    { label: "Yesterday", pairs: [["after", "yesterday"], ["before", "today"]] },150    { label: "Last 7 days", pairs: [["after", "7d"]] },151    { label: "Last 30 days", pairs: [["after", "30d"]] },152    { label: "This month", pairs: [["after", "month"]] },153  ];154155  const rowCls = cn("flex w-full items-center gap-3 rounded-lg px-3 text-left transition-colors hover:bg-bg-muted active:bg-bg-muted [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-fg-muted", isMobile ? "min-h-[50px] text-[15px]" : "min-h-[40px] text-[13.5px]");156157  return (158    <ResponsiveDialog open={picker !== null} onOpenChange={(o) => !o && close()} title={`Filter by ${title.toLowerCase()}`} size="sm" snap="half" expandable flush desktopFlush>159      {picker === "date" ? (160        <div className="px-3 pb-4 pt-1">161          <ul className="space-y-0.5">162            {dateOptions.map((d) => (163              <li key={d.label}>164                <button type="button" className={rowCls} onClick={() => { onPickMany(d.pairs); close(); }}>165                  <Calendar />166                  <span className="flex-1">{d.label}</span>167                  <span className="font-mono text-[11px] text-fg-subtle">{d.pairs.map(([k, v]) => `${k}:${v}`).join(" ")}</span>168                </button>169              </li>170            ))}171          </ul>172          <div className="mt-3 rounded-xl bg-bg-subtle p-3">173            <p className="mb-2 text-[12px] font-medium text-fg-muted">Custom range</p>174            <div className="grid grid-cols-2 gap-2">175              <label className="text-[11.5px] text-fg-subtle">176                After177                <input type="date" value={after} onChange={(e) => setAfter(e.target.value)} className="mt-1 block h-10 w-full rounded-md border border-border bg-bg-elevated px-2 text-[15px] text-fg sm:h-9 sm:text-sm" />178              </label>179              <label className="text-[11.5px] text-fg-subtle">180                Before181                <input type="date" value={before} onChange={(e) => setBefore(e.target.value)} className="mt-1 block h-10 w-full rounded-md border border-border bg-bg-elevated px-2 text-[15px] text-fg sm:h-9 sm:text-sm" />182              </label>183            </div>184            <Button185              className="mt-3 w-full"186              size={isMobile ? "lg" : "md"}187              disabled={!after && !before}188              onClick={() => {189                const pairs: [FilterKey, string][] = [];190                if (after) pairs.push(["after", after]);191                if (before) pairs.push(["before", before]);192                onPickMany(pairs);193                close();194              }}195            >196              Apply range197            </Button>198          </div>199        </div>200      ) : (201        <div className="px-3 pb-3 pt-1">202          {searchable ? (203            <div className="relative mb-2">204              <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-subtle" />205              <input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`Filter ${title.toLowerCase()}s…`} className="h-11 w-full rounded-lg border border-border bg-bg-elevated pl-9 pr-3 text-[16px] text-fg outline-none placeholder:text-fg-subtle focus:border-accent sm:h-9 sm:text-sm" autoFocus={!isMobile} aria-label={`Filter ${title.toLowerCase()}s`} />206            </div>207          ) : null}208          {picker === "project" && projectsQ.error ? <p className="px-3 py-6 text-center text-[13px] text-fg-muted">Projects are not available yet.</p> : null}209          {options.length === 0 && !(picker === "project" && projectsQ.error) ? <p className="px-3 py-6 text-center text-[13px] text-fg-muted">{picker === "project" && projectsQ.isLoading ? "Loading…" : "Nothing to pick from."}</p> : null}210          <ul className="max-h-[50dvh] space-y-0.5 overflow-y-auto scrollbar-thin md:max-h-80">211            {options.map((o) => (212              <li key={o.value}>213                <button type="button" className={rowCls} onClick={() => pick(key, o.value)}>214                  {o.icon}215                  <span className="min-w-0 flex-1 truncate">{o.label}</span>216                  {o.hint ? <span className="max-w-[40%] truncate text-[11.5px] text-fg-subtle">{o.hint}</span> : null}217                </button>218              </li>219            ))}220          </ul>221          {picker === "model" && !q ? (222            <p className="mt-2 px-3 text-[11.5px] text-fg-subtle">223              Tip: <code className="font-mono">model:{AUTO_MODEL_KEY}</code> is not a filter — pick a real model or type any part of a name.224            </p>225          ) : null}226        </div>227      )}228    </ResponsiveDialog>229  );230}231