AI Patterns

tables

Describe Filters

A Describe button in a table toolbar that turns a plain-language request into removable filter chips — column filters where a column exists, marked semantic matches where none does.

    PatternCategoryUpdatedStatus
    Tool ApprovalpermissionsOct 28, 2026Stable
    Human in the LoopagentsOct 21, 2026Stable
    Thinking LoaderloadersSep 12, 2026Stable
    Analysis ListloadersOct 30, 2026Beta
    Background TasksagentsOct 14, 2026Beta
    Rate LimiterrorsOct 2, 2026Stable
    Generation ErrorerrorsNov 1, 2026Stable
    Listening StatevoiceAug 19, 2026Beta
    Live TranscriptvoiceSep 3, 2026Beta
    Confidence IndicatorindicatorsOct 9, 2026Stable
    Source Trust CardtextOct 25, 2026Stable
    Multi-Agent TracetracesSep 27, 2026Beta
    "use client";
    
    import * as React from "react";
    import { AnimatePresence, motion } from "motion/react";
    import { ArrowUp, ListFilter, Search, Sparkles, X } from "lucide-react";
    
    import { cn } from "@/lib/utils";
    
    export interface DescribeFiltersColumn<Row> {
      key: string;
      label: string;
      /** Renders the cell; defaults to `String(row[key])`. */
      render?: (row: Row) => React.ReactNode;
      className?: string;
    }
    
    export interface DescribedFilter<Row> {
      id: string;
      /** Column label, or a concept label like "About" when no column covers the request. */
      field: string;
      operator: string;
      value: string;
      /**
       * `column` filters map onto a real column. `semantic` filters have no
       * column behind them — the model matched rows by meaning — so the chip is
       * marked differently and the user knows it can't be edited like a column.
       */
      kind: "column" | "semantic";
      test: (row: Row) => boolean;
    }
    
    export interface DescribeResult<Row> {
      filters: DescribedFilter<Row>[];
    }
    
    export interface DescribeFiltersTableProps<Row extends { id: string }> {
      columns: DescribeFiltersColumn<Row>[];
      rows: Row[];
      /** Turns a plain-language description into filters. */
      onDescribe: (description: string) => Promise<DescribeResult<Row>>;
      /** Used by the plain keyword search box. */
      searchText?: (row: Row) => string;
      /** Example descriptions shown while the describe field is empty. */
      suggestions?: string[];
      initialFilters?: DescribedFilter<Row>[];
      describePlaceholder?: string;
      className?: string;
    }
    
    export function DescribeFiltersTable<Row extends { id: string }>({
      columns,
      rows,
      onDescribe,
      searchText,
      suggestions = [],
      initialFilters = [],
      describePlaceholder = "Describe the rows you want to see…",
      className,
    }: DescribeFiltersTableProps<Row>) {
      const [mode, setMode] = React.useState<"idle" | "describing" | "pending">("idle");
      const [draft, setDraft] = React.useState("");
      const [lastDescription, setLastDescription] = React.useState<string | null>(null);
      const [filters, setFilters] = React.useState(initialFilters);
      /** Ids of filters produced by the last description — they get the AI marker. */
      const [generatedIds, setGeneratedIds] = React.useState<Set<string>>(new Set());
      const [query, setQuery] = React.useState("");
      const [error, setError] = React.useState<string | null>(null);
    
      const describeRef = React.useRef<HTMLInputElement>(null);
      const describeButtonRef = React.useRef<HTMLButtonElement>(null);
      const restoreFocus = React.useRef(false);
    
      React.useEffect(() => {
        if (mode === "describing") describeRef.current?.focus();
        if (mode === "idle" && restoreFocus.current) {
          restoreFocus.current = false;
          describeButtonRef.current?.focus();
        }
      }, [mode]);
    
      const visibleRows = React.useMemo(() => {
        const q = query.trim().toLowerCase();
        return rows.filter(
          (row) =>
            filters.every((f) => f.test(row)) && (!q || (searchText?.(row) ?? "").toLowerCase().includes(q))
        );
      }, [rows, filters, query, searchText]);
    
      function openDescribe(prefill = "") {
        setDraft(prefill);
        setError(null);
        setMode("describing");
      }
    
      function cancelDescribe() {
        restoreFocus.current = true;
        setMode("idle");
      }
    
      async function submit(description: string) {
        const trimmed = description.trim();
        if (!trimmed || mode === "pending") return;
        setDraft(trimmed);
        setMode("pending");
        setError(null);
        try {
          const result = await onDescribe(trimmed);
          // A description replaces the previous generated set but keeps filters
          // the user added by hand.
          setFilters((prev) => [...prev.filter((f) => !generatedIds.has(f.id)), ...result.filters]);
          setGeneratedIds(new Set(result.filters.map((f) => f.id)));
          setLastDescription(trimmed);
          restoreFocus.current = true;
          setMode("idle");
        } catch {
          setError("Couldn't turn that into filters. Try rephrasing.");
          setMode("describing");
        }
      }
    
      function removeFilter(id: string) {
        setFilters((prev) => prev.filter((f) => f.id !== id));
        setGeneratedIds((prev) => {
          const next = new Set(prev);
          next.delete(id);
          if (next.size === 0) setLastDescription(null);
          return next;
        });
      }
    
      function clearAll() {
        setFilters([]);
        setGeneratedIds(new Set());
        setLastDescription(null);
      }
    
      const busy = mode === "pending";
      const showSuggestions = mode === "describing" && !draft && suggestions.length > 0;
    
      return (
        <div className={cn("w-full overflow-hidden rounded-2xl border bg-card text-sm shadow-sm", className)}>
          {/* Toolbar */}
          <div className="border-b">
            <div className="flex min-h-14 items-center gap-2 px-3 py-2.5">
              <AnimatePresence mode="popLayout" initial={false}>
                {mode === "idle" ? (
                  <motion.div
                    key="toolbar"
                    initial={{ opacity: 0, y: 4 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={{ opacity: 0, y: -4 }}
                    transition={{ duration: 0.15 }}
                    className="flex min-w-0 flex-1 flex-wrap items-center gap-2"
                  >
                    <button
                      ref={describeButtonRef}
                      type="button"
                      onClick={() => openDescribe()}
                      className="flex h-8 shrink-0 items-center gap-1.5 rounded-full border bg-background px-3 font-medium transition-colors hover:bg-accent"
                    >
                      <Sparkles className="size-3.5" aria-hidden />
                      Describe
                    </button>
    
                    <label className="flex h-8 w-40 shrink-0 items-center gap-2 rounded-full border bg-background px-3 text-muted-foreground focus-within:ring-2 focus-within:ring-ring/40">
                      <Search className="size-3.5 shrink-0" aria-hidden />
                      <input
                        value={query}
                        onChange={(e) => setQuery(e.target.value)}
                        placeholder="Search"
                        aria-label="Search rows"
                        className="min-w-0 flex-1 bg-transparent text-foreground outline-none placeholder:text-muted-foreground"
                      />
                    </label>
    
                    {filters.length > 0 && (
                      <span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground" aria-hidden>
                        <ListFilter className="size-4" />
                      </span>
                    )}
    
                    <ul className="contents" aria-label="Active filters">
                      <AnimatePresence initial={false}>
                        {filters.map((f, i) => (
                          <FilterChip
                            key={f.id}
                            filter={f}
                            generated={generatedIds.has(f.id)}
                            delay={generatedIds.has(f.id) ? i * 0.06 : 0}
                            onRemove={() => removeFilter(f.id)}
                          />
                        ))}
                      </AnimatePresence>
                    </ul>
    
                    {filters.length > 1 && (
                      <button
                        type="button"
                        onClick={clearAll}
                        className="h-8 shrink-0 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:text-foreground"
                      >
                        Clear all
                      </button>
                    )}
                  </motion.div>
                ) : (
                  <motion.form
                    key="describe"
                    initial={{ opacity: 0, y: 4 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={{ opacity: 0, y: -4 }}
                    transition={{ duration: 0.15 }}
                    onSubmit={(e) => {
                      e.preventDefault();
                      submit(draft);
                    }}
                    onKeyDown={(e) => {
                      if (e.key === "Escape" && !busy) {
                        e.preventDefault();
                        cancelDescribe();
                      }
                    }}
                    className="flex min-w-0 flex-1 items-center gap-2"
                  >
                    <button
                      type="button"
                      onClick={cancelDescribe}
                      disabled={busy}
                      aria-label="Cancel describing"
                      className="flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
                    >
                      <X className="size-4" />
                    </button>
                    <div className="relative min-w-0 flex-1">
                      <input
                        ref={describeRef}
                        value={draft}
                        onChange={(e) => {
                          setDraft(e.target.value);
                          setError(null);
                        }}
                        readOnly={busy}
                        placeholder={describePlaceholder}
                        aria-label="Describe filters"
                        aria-describedby={error ? "describe-filters-error" : undefined}
                        className={cn(
                          "h-8 w-full bg-transparent outline-none placeholder:text-muted-foreground",
                          busy && "text-muted-foreground"
                        )}
                      />
                    </div>
                    {busy ? (
                      <span className="flex shrink-0 items-center gap-1.5 pr-1 text-xs text-muted-foreground" role="status">
                        <Sparkles className="size-3.5 animate-spin [animation-duration:2s]" aria-hidden />
                        <span className="animate-pulse">Building filters</span>
                      </span>
                    ) : (
                      <button
                        type="submit"
                        disabled={!draft.trim()}
                        aria-label="Apply description"
                        className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity disabled:opacity-30"
                      >
                        <ArrowUp className="size-4" />
                      </button>
                    )}
                  </motion.form>
                )}
              </AnimatePresence>
            </div>
    
            {/* Suggestions & errors expand inline under the field and push the
                table down — an overlay would cover the header and spill out of
                the card when the table is short. */}
            <AnimatePresence initial={false}>
              {(showSuggestions || error) && (
                <motion.div
                  initial={{ height: 0, opacity: 0 }}
                  animate={{ height: "auto", opacity: 1 }}
                  exit={{ height: 0, opacity: 0 }}
                  transition={{ duration: 0.18 }}
                  className="overflow-hidden"
                >
                  {error ? (
                    <p id="describe-filters-error" role="alert" className="px-4 pb-3 text-xs text-destructive">
                      {error}
                    </p>
                  ) : (
                    <div className="px-2 pb-2">
                      <p className="px-3 pb-1 text-xs text-muted-foreground">Try</p>
                      {suggestions.map((s) => (
                        <button
                          key={s}
                          type="button"
                          onClick={() => submit(s)}
                          className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition-colors hover:bg-accent"
                        >
                          <Sparkles className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
                          {s}
                        </button>
                      ))}
                    </div>
                  )}
                </motion.div>
              )}
            </AnimatePresence>
          </div>
    
          {/* Interpretation line: what was asked, and how to revise it. */}
          <AnimatePresence initial={false}>
            {lastDescription && mode === "idle" && (
              <motion.div
                initial={{ height: 0, opacity: 0 }}
                animate={{ height: "auto", opacity: 1 }}
                exit={{ height: 0, opacity: 0 }}
                transition={{ duration: 0.18 }}
                className="overflow-hidden border-b bg-muted/40"
              >
                <p className="flex items-center gap-1.5 px-4 py-2 text-xs text-muted-foreground" aria-live="polite">
                  <Sparkles className="size-3 shrink-0" aria-hidden />
                  <span className="min-w-0 truncate">
                    Filtered for <span className="text-foreground">“{lastDescription}”</span> · {visibleRows.length} of{" "}
                    {rows.length}
                  </span>
                  <button
                    type="button"
                    onClick={() => openDescribe(lastDescription)}
                    className="ml-auto shrink-0 font-medium text-foreground underline-offset-2 hover:underline"
                  >
                    Edit
                  </button>
                </p>
              </motion.div>
            )}
          </AnimatePresence>
    
          {/* Table */}
          <div className="max-h-80 overflow-auto">
            <table className="w-full border-collapse" aria-busy={busy}>
              <thead className="sticky top-0 z-[1] bg-card">
                <tr className="border-b text-left text-xs text-muted-foreground">
                  {columns.map((c) => (
                    <th key={c.key} scope="col" className={cn("px-4 py-2.5 font-medium", c.className)}>
                      {c.label}
                    </th>
                  ))}
                </tr>
              </thead>
              <motion.tbody animate={{ opacity: busy ? 0.4 : 1 }} transition={{ duration: 0.2 }}>
                <AnimatePresence initial={false}>
                  {visibleRows.map((row) => (
                    <motion.tr
                      key={row.id}
                      layout="position"
                      initial={{ opacity: 0 }}
                      animate={{ opacity: 1 }}
                      exit={{ opacity: 0 }}
                      transition={{ duration: 0.18 }}
                      className="border-b last:border-b-0"
                    >
                      {columns.map((c) => (
                        <td key={c.key} className={cn("px-4 py-2.5", c.className)}>
                          {c.render ? c.render(row) : String((row as Record<string, unknown>)[c.key] ?? "")}
                        </td>
                      ))}
                    </motion.tr>
                  ))}
                </AnimatePresence>
              </motion.tbody>
            </table>
            {visibleRows.length === 0 && (
              <div className="flex flex-col items-center gap-2 px-4 py-10 text-center">
                <p className="text-muted-foreground">No rows match these filters.</p>
                {lastDescription && (
                  <button
                    type="button"
                    onClick={() => openDescribe(lastDescription)}
                    className="text-xs font-medium underline-offset-2 hover:underline"
                  >
                    Edit description
                  </button>
                )}
              </div>
            )}
          </div>
        </div>
      );
    }
    
    function FilterChip<Row>({
      filter,
      generated,
      delay,
      onRemove,
    }: {
      filter: DescribedFilter<Row>;
      generated: boolean;
      delay: number;
      onRemove: () => void;
    }) {
      const semantic = filter.kind === "semantic";
      const label = `${filter.field} ${filter.operator} ${filter.value}`;
      return (
        <motion.li
          layout
          initial={{ opacity: 0, scale: 0.92 }}
          animate={{ opacity: 1, scale: 1, transition: { delay, duration: 0.18 } }}
          exit={{ opacity: 0, scale: 0.92, transition: { duration: 0.12 } }}
          title={semantic ? "No column covers this — rows were matched by meaning" : undefined}
          className={cn(
            "flex h-8 shrink-0 items-center gap-1.5 rounded-full border bg-background pl-3 pr-1",
            semantic && "border-dashed"
          )}
        >
          {generated && <Sparkles className="size-3 shrink-0 text-muted-foreground" aria-label="Generated" />}
          <span className={cn(semantic && "italic")}>{filter.field}</span>
          <span className="text-muted-foreground">· {filter.operator} ·</span>
          <span className="max-w-48 truncate">{filter.value}</span>
          <button
            type="button"
            onClick={onRemove}
            aria-label={`Remove filter: ${label}`}
            className="flex size-6 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
          >
            <X className="size-3.5" />
          </button>
        </motion.li>
      );
    }
    

    A UX spec for this pattern — written for agents implementing or reusing it, not the code.

    # Describe Filters
    
    ## Summary
    A "Describe" button in a table's toolbar that turns into a free-text field where the user says, in plain language, which rows they want ("Show me patterns for agent oversight"). The model translates the description into ordinary, removable filter chips. When a request maps onto a real column it becomes a column filter (Status · is · Beta); when no column covers it, it becomes a semantic filter (About · matches · agent oversight) that matches rows by meaning, visibly marked as such.
    
    ## When to use
    - Tables with many columns or operators, where building a filter by hand takes several clicks.
    - Requests that cut across the schema — topics, intents, or qualities that no single column captures ("gear for video calls", "customers at risk", "patterns for agent oversight").
    - Alongside the existing keyword search and manual filter UI, as a faster path — never as the only way to filter.
    
    ## When not to use
    - Small tables where a single dropdown or the keyword search already does the job.
    - When results must be exact and auditable (financial reconciliation, compliance exports) and you can't express everything as column filters — don't let a semantic match silently decide what's in the set.
    - As a chat interface. The output is filters on this table, not an answer or a summary.
    
    ## Anatomy
    - **Describe button** — a pill with a sparkle icon, first in the toolbar, next to keyword search.
    - **Describe field** — replaces the toolbar in place: cancel (×), text input, submit arrow.
    - **Suggestions** — a short list of example descriptions that expands inline under the field (pushing the table down, never overlaying it), shown only while the field is empty.
    - **Pending state** — inline "Building filters" status in the field; the table dims but keeps its rows.
    - **Filter chips** — Field · operator · value, each removable. Chips from the last description carry a small sparkle; semantic chips use a dashed border and an italic field name.
    - **Interpretation bar** — a thin line under the toolbar: the description that produced the filters, the resulting row count ("6 of 24"), and an Edit action.
    - **Empty state** — "No rows match these filters" with an "Edit description" action.
    
    ## Behavior
    - Clicking Describe swaps the toolbar for the describe field with a short cross-fade and focuses the input. Esc or × restores the toolbar and returns focus to the Describe button.
    - Enter (or a suggestion click) submits. While pending, the input is read-only and the table dims; existing rows stay visible so the layout doesn't jump.
    - On success, the toolbar returns and the new chips animate in with a small stagger, then rows filter out with a fade and the remaining rows slide up.
    - A new description replaces the chips from the previous description but keeps filters the user added by hand.
    - Every generated chip is an ordinary filter: removable one by one, with "Clear all" when more than one is active. Removing the last generated chip hides the interpretation bar.
    - Prefer column filters. Only fall back to a semantic filter when no column, or combination of columns, expresses the request — and say so on the chip (tooltip: "No column covers this — rows were matched by meaning").
    - Edit (in the interpretation bar or the empty state) reopens the describe field pre-filled with the last description.
    - On failure, keep the field open with the text intact and show an inline error under it — never clear what the user typed.
    
    ## Content guidelines
    - Button label: "Describe". Placeholder speaks in the user's voice and names the table's subject: "Show me patterns for…", "Show me orders where…".
    - Suggestions should demonstrate range: one that needs a semantic match, one that combines a concept with a column, one that only uses columns.
    - Chip values are short and literal: the column's own vocabulary and formatting for column filters ("Oct 1, 2026", not "the start of October"); a 1–3 word concept for semantic ones.
    - The interpretation bar quotes the description verbatim — don't rewrite what the user said.
    
    ## Accessibility
    - The Describe button, chip remove buttons, and Edit are real `<button>`s; each remove button is labelled with the full filter ("Remove filter: Status is Beta").
    - Active filters are a labelled list. The generated-chip sparkle has an accessible label ("Generated"); the semantic marker isn't conveyed by the dashed border alone — the italic field name and tooltip back it up, and the field name itself ("About") says it's not a column.
    - The pending status is a `role="status"` region and the table carries `aria-busy` while pending; the interpretation bar is `aria-live="polite"` so the new row count is announced.
    - Focus moves into the field on open and back to the Describe button on close or apply.
    - Animations are short opacity/position transitions; respect `prefers-reduced-motion` by dropping the stagger and row slide.
    
    ## Related patterns
    - Command Palette — keyword jump to a known item; Describe Filters narrows a set by intent.
    - Refinement Controls — another free-form instruction that produces a reviewable, reversible result.
    - Confidence Indicator — pair with semantic chips when the model can report how sure the match is.