TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
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