refactor(web): extract QueryInput as provider-based composition component

Quentin Gliech and Claude Opus 4.6 (1M context) created

New generic QueryInput with pluggable CompletionProviders:
- Root manages state (cursor tracking, keyboard nav, suggestions) via context
- Input renders the two-layer syntax-highlighted input
- Completions renders the autocomplete dropdown
- Icon is a positioned slot

Providers define prefix, highlight color, and getSuggestions (sync or async).
Adding a new filter type means adding a provider β€” zero component changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Change summary

webui2/src/components/ui/query-input.stories.tsx | 150 +++++
webui2/src/components/ui/query-input.tsx         | 456 ++++++++++++++++++
webui2/src/routes/$repo/_issues/issues/index.tsx |  79 ++
3 files changed, 672 insertions(+), 13 deletions(-)

Detailed changes

webui2/src/components/ui/query-input.stories.tsx πŸ”—

@@ -0,0 +1,150 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Search } from "lucide-react";
+import { useState } from "react";
+
+import type { CompletionProvider } from "./query-input";
+import * as QueryInput from "./query-input";
+
+const meta = {
+  component: QueryInput.Root,
+} satisfies Meta<typeof QueryInput.Root>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const sampleLabels = [
+  { name: "bug", color: { R: 252, G: 41, B: 41 } },
+  { name: "enhancement", color: { R: 163, G: 230, B: 53 } },
+  { name: "documentation", color: { R: 30, G: 80, B: 160 } },
+  { name: "help wanted", color: { R: 0, G: 150, B: 136 } },
+  { name: "wontfix", color: { R: 200, G: 200, B: 200 } },
+];
+
+const sampleAuthors = [
+  { displayName: "Jane Doe", login: "janedoe" },
+  { displayName: "Bob Smith", login: "bobsmith" },
+  { displayName: "Alice Wu", login: "alicewu" },
+];
+
+const providers: CompletionProvider[] = [
+  {
+    prefix: "label:",
+    highlightClass: "text-yellow-600 dark:text-yellow-500",
+    getSuggestions: (query) =>
+      sampleLabels
+        .filter((l) => query === "" || l.name.toLowerCase().includes(query.toLowerCase()))
+        .map((l) => ({
+          value: l.name.includes(" ") ? `"${l.name}"` : l.name,
+          label: l.name,
+          icon: (
+            <span
+              className="size-2 shrink-0 rounded-full"
+              style={{ backgroundColor: `rgb(${l.color.R},${l.color.G},${l.color.B})` }}
+            />
+          ),
+        })),
+  },
+  {
+    prefix: "author:",
+    highlightClass: "text-blue-600 dark:text-blue-400",
+    getSuggestions: (query) =>
+      sampleAuthors
+        .filter(
+          (a) =>
+            query === "" ||
+            a.displayName.toLowerCase().includes(query.toLowerCase()) ||
+            a.login.toLowerCase().includes(query.toLowerCase()),
+        )
+        .map((a) => ({
+          value: a.login,
+          label: a.displayName,
+          description: `@${a.login}`,
+        })),
+  },
+];
+
+export const Default: Story = {
+  args: { children: null, value: "", onChange: () => {}, onSubmit: () => {} },
+  render: () => {
+    const [value, setValue] = useState("status:open");
+    return (
+      <QueryInput.Root
+        value={value}
+        onChange={setValue}
+        onSubmit={() => {}}
+        providers={providers}
+      >
+        <QueryInput.Icon><Search /></QueryInput.Icon>
+        <QueryInput.Input placeholder="status:open author:… label:…" />
+        <QueryInput.Completions />
+      </QueryInput.Root>
+    );
+  },
+};
+
+export const WithFilters: Story = {
+  args: { children: null, value: "", onChange: () => {}, onSubmit: () => {} },
+  render: () => {
+    const [value, setValue] = useState('status:open label:bug author:janedoe fix login');
+    return (
+      <QueryInput.Root
+        value={value}
+        onChange={setValue}
+        onSubmit={() => {}}
+        providers={providers}
+      >
+        <QueryInput.Icon><Search /></QueryInput.Icon>
+        <QueryInput.Input placeholder="status:open author:… label:…" />
+        <QueryInput.Completions />
+      </QueryInput.Root>
+    );
+  },
+};
+
+export const SyntaxOnly: Story = {
+  args: { children: null, value: "", onChange: () => {}, onSubmit: () => {} },
+  render: () => {
+    const [value, setValue] = useState("status:open label:bug");
+    return (
+      <QueryInput.Root value={value} onChange={setValue} onSubmit={() => {}}>
+        <QueryInput.Icon><Search /></QueryInput.Icon>
+        <QueryInput.Input placeholder="Search…" />
+      </QueryInput.Root>
+    );
+  },
+};
+
+const asyncProviders: CompletionProvider[] = [
+  {
+    prefix: "label:",
+    highlightClass: "text-yellow-600 dark:text-yellow-500",
+    getSuggestions: async (query) => {
+      await new Promise((r) => setTimeout(r, 500));
+      return sampleLabels
+        .filter((l) => query === "" || l.name.toLowerCase().includes(query.toLowerCase()))
+        .map((l) => ({
+          value: l.name.includes(" ") ? `"${l.name}"` : l.name,
+          label: l.name,
+        }));
+    },
+  },
+];
+
+export const AsyncCompletions: Story = {
+  args: { children: null, value: "", onChange: () => {}, onSubmit: () => {} },
+  render: () => {
+    const [value, setValue] = useState("");
+    return (
+      <QueryInput.Root
+        value={value}
+        onChange={setValue}
+        onSubmit={() => {}}
+        providers={asyncProviders}
+      >
+        <QueryInput.Icon><Search /></QueryInput.Icon>
+        <QueryInput.Input placeholder="Type label: to see async loading…" />
+        <QueryInput.Completions />
+      </QueryInput.Root>
+    );
+  },
+};

webui2/src/components/ui/query-input.tsx πŸ”—

@@ -0,0 +1,456 @@
+// Generic syntax-highlighted search input with pluggable autocomplete providers.
+//
+// Architecture: two layers share the same font/padding so they appear identical:
+//   1. A "backdrop" div (aria-hidden) renders colored <span>s for each token.
+//   2. The real <input> floats on top with transparent text and bg, so the caret
+//      is visible but the text itself is hidden in favour of the backdrop.
+
+import {
+  createContext,
+  useContext,
+  useState,
+  useRef,
+  useMemo,
+  useEffect,
+  type ChangeEvent,
+  type ReactNode,
+} from "react";
+
+import { cn } from "@/lib/utils";
+
+// ── Public types ──────────────────────────────────────────────────────────────
+
+export interface Suggestion {
+  /** What gets inserted into the input (already quoted if needed). */
+  value: string;
+  /** Display label shown in the dropdown. */
+  label: string;
+  /** Optional leading decoration (icon, color dot, etc.). */
+  icon?: ReactNode;
+  /** Optional right-aligned secondary text. */
+  description?: string;
+}
+
+export interface CompletionProvider {
+  /** The prefix this provider handles, including the colon (e.g. "label:"). */
+  prefix: string;
+  /** Tailwind classes for syntax-highlighting this prefix in the backdrop. */
+  highlightClass: string;
+  /** Return suggestions for the partial query typed after the prefix. */
+  getSuggestions(query: string): Suggestion[] | Promise<Suggestion[]>;
+}
+
+/** Static syntax rules for tokens that aren't completable but should be colored. */
+export interface SyntaxRule {
+  /** Exact token match (e.g. "status:open") or a prefix match (e.g. "sort:"). */
+  match: string | ((token: string) => boolean);
+  /** Tailwind classes for the prefix/key portion. */
+  highlightClass: string;
+}
+
+// ── Defaults ──────────────────────────────────────────────────────────────────
+
+const DEFAULT_SYNTAX_RULES: SyntaxRule[] = [
+  { match: "status:open", highlightClass: "text-green-600 dark:text-green-400" },
+  { match: "status:closed", highlightClass: "text-purple-600 dark:text-purple-400" },
+  { match: (t) => t.startsWith("sort:"), highlightClass: "text-orange-600 dark:text-orange-400" },
+];
+
+// ── Segment parsing ───────────────────────────────────────────────────────────
+
+interface Segment {
+  text: string;
+  highlightClass: string | null;
+}
+
+function parseSegments(
+  input: string,
+  providers: CompletionProvider[],
+  syntaxRules: SyntaxRule[],
+): Segment[] {
+  const segments: Segment[] = [];
+  let i = 0;
+
+  while (i < input.length) {
+    // Whitespace runs
+    if (input[i] === " ") {
+      let j = i;
+      while (j < input.length && input[j] === " ") j++;
+      segments.push({ text: input.slice(i, j), highlightClass: null });
+      i = j;
+      continue;
+    }
+
+    // Token β€” consume until an unquoted space
+    let j = i;
+    let inQuote = false;
+    while (j < input.length) {
+      if (input[j] === '"') {
+        inQuote = !inQuote;
+        j++;
+        continue;
+      }
+      if (!inQuote && input[j] === " ") break;
+      j++;
+    }
+
+    const token = input.slice(i, j);
+
+    // Check providers first (they also define syntax highlighting)
+    let highlightClass: string | null = null;
+    for (const p of providers) {
+      if (token.startsWith(p.prefix)) {
+        highlightClass = p.highlightClass;
+        break;
+      }
+    }
+
+    // Then check static syntax rules
+    if (!highlightClass) {
+      for (const rule of syntaxRules) {
+        const matches =
+          typeof rule.match === "string" ? token === rule.match : rule.match(token);
+        if (matches) {
+          highlightClass = rule.highlightClass;
+          break;
+        }
+      }
+    }
+
+    segments.push({ text: token, highlightClass });
+    i = j;
+  }
+
+  return segments;
+}
+
+function renderSegment(seg: Segment, i: number): ReactNode {
+  if (!seg.highlightClass) {
+    return <span key={i}>{seg.text}</span>;
+  }
+  const colon = seg.text.indexOf(":");
+  if (colon === -1) {
+    return (
+      <span key={i} className={seg.highlightClass}>
+        {seg.text}
+      </span>
+    );
+  }
+  const key = seg.text.slice(0, colon + 1);
+  const val = seg.text.slice(colon + 1);
+  return (
+    <span key={i}>
+      <span className={seg.highlightClass}>{key}</span>
+      <span>{val}</span>
+    </span>
+  );
+}
+
+// ── Cursor / token utilities ──────────────────────────────────────────────────
+
+interface CompletionInfo {
+  provider: CompletionProvider;
+  query: string;
+  tokenStart: number;
+}
+
+function getCompletionInfo(
+  value: string,
+  cursor: number,
+  providers: CompletionProvider[],
+): CompletionInfo | null {
+  let tokenStart = 0;
+  for (let i = cursor - 1; i >= 0; i--) {
+    if (value[i] === " ") {
+      tokenStart = i + 1;
+      break;
+    }
+  }
+
+  const partial = value.slice(tokenStart, cursor);
+  for (const provider of providers) {
+    if (partial.startsWith(provider.prefix)) {
+      const query = partial.slice(provider.prefix.length).replace(/^"/, "");
+      return { provider, query, tokenStart };
+    }
+  }
+  return null;
+}
+
+function getTokenEnd(value: string, tokenStart: number): number {
+  let inQuote = false;
+  for (let i = tokenStart; i < value.length; i++) {
+    if (value[i] === '"') {
+      inQuote = !inQuote;
+      continue;
+    }
+    if (!inQuote && value[i] === " ") return i;
+  }
+  return value.length;
+}
+
+// ── Context ───────────────────────────────────────────────────────────────────
+
+interface QueryInputContextValue {
+  value: string;
+  segments: Segment[];
+  inputRef: React.RefObject<HTMLInputElement | null>;
+  suggestions: Suggestion[];
+  activeIndex: number;
+  showDropdown: boolean;
+  loading: boolean;
+  handleChange: (e: ChangeEvent<HTMLInputElement>) => void;
+  handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
+  handleSelect: (e: React.SyntheticEvent<HTMLInputElement>) => void;
+  selectSuggestion: (index: number) => void;
+}
+
+const QueryInputContext = createContext<QueryInputContextValue | null>(null);
+
+function useQueryInput() {
+  const ctx = useContext(QueryInputContext);
+  if (!ctx) throw new Error("QueryInput sub-components must be used within QueryInput.Root");
+  return ctx;
+}
+
+// ── Components ────────────────────────────────────────────────────────────────
+
+interface RootProps {
+  value: string;
+  onChange: (value: string) => void;
+  onSubmit: () => void;
+  providers?: CompletionProvider[];
+  syntaxRules?: SyntaxRule[];
+  className?: string;
+  children: ReactNode;
+}
+
+export function Root({
+  value,
+  onChange,
+  onSubmit,
+  providers = [],
+  syntaxRules = DEFAULT_SYNTAX_RULES,
+  className,
+  children,
+}: RootProps) {
+  const inputRef = useRef<HTMLInputElement>(null);
+  const [completion, setCompletion] = useState<CompletionInfo | null>(null);
+  const [activeIndex, setActiveIndex] = useState(0);
+  const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
+  const [loading, setLoading] = useState(false);
+
+  const segments = useMemo(
+    () => parseSegments(value, providers, syntaxRules),
+    [value, providers, syntaxRules],
+  );
+
+  // Fetch suggestions when completion changes
+  useEffect(() => {
+    if (!completion) {
+      setSuggestions([]);
+      setLoading(false);
+      return;
+    }
+
+    let cancelled = false;
+    const result = completion.provider.getSuggestions(completion.query);
+
+    if (result instanceof Promise) {
+      setLoading(true);
+      void result.then((items) => {
+        if (!cancelled) {
+          setSuggestions(items);
+          setLoading(false);
+        }
+      });
+    } else {
+      setSuggestions(result);
+      setLoading(false);
+    }
+
+    return () => {
+      cancelled = true;
+    };
+  }, [completion]);
+
+  function updateCompletion(newValue: string, cursor: number) {
+    const info = getCompletionInfo(newValue, cursor, providers);
+    setCompletion(info);
+    setActiveIndex(0);
+  }
+
+  function handleChange(e: ChangeEvent<HTMLInputElement>) {
+    const newValue = e.target.value;
+    const cursor = e.target.selectionStart ?? newValue.length;
+    onChange(newValue);
+    updateCompletion(newValue, cursor);
+  }
+
+  function handleSelect(e: React.SyntheticEvent<HTMLInputElement>) {
+    updateCompletion(value, e.currentTarget.selectionStart ?? value.length);
+  }
+
+  function applySuggestion(s: Suggestion) {
+    if (!completion) return;
+    const tokenEnd = getTokenEnd(value, completion.tokenStart);
+    const completedToken = `${completion.provider.prefix}${s.value}`;
+    const newValue =
+      value.slice(0, completion.tokenStart) +
+      completedToken +
+      " " +
+      value.slice(tokenEnd).trimStart();
+    onChange(newValue);
+    setCompletion(null);
+    setSuggestions([]);
+
+    const newCursor = completion.tokenStart + completedToken.length + 1;
+    requestAnimationFrame(() => {
+      inputRef.current?.focus();
+      inputRef.current?.setSelectionRange(newCursor, newCursor);
+    });
+  }
+
+  function selectSuggestion(index: number) {
+    const s = suggestions[index];
+    if (s) applySuggestion(s);
+  }
+
+  function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
+    if (e.key === "Enter" && !completion) {
+      e.preventDefault();
+      onSubmit();
+      return;
+    }
+
+    if (!completion || suggestions.length === 0) return;
+
+    if (e.key === "ArrowDown") {
+      e.preventDefault();
+      setActiveIndex((i) => (i + 1) % suggestions.length);
+    } else if (e.key === "ArrowUp") {
+      e.preventDefault();
+      setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length);
+    } else if (e.key === "Enter" || e.key === "Tab") {
+      e.preventDefault();
+      selectSuggestion(activeIndex);
+    } else if (e.key === "Escape") {
+      setCompletion(null);
+      setSuggestions([]);
+    }
+  }
+
+  const showDropdown = suggestions.length > 0;
+
+  const ctx: QueryInputContextValue = {
+    value,
+    segments,
+    inputRef,
+    suggestions,
+    activeIndex,
+    showDropdown,
+    loading,
+    handleChange,
+    handleKeyDown,
+    handleSelect,
+    selectSuggestion,
+  };
+
+  return (
+    <QueryInputContext value={ctx}>
+      <div
+        className={cn(
+          "relative flex flex-1 items-center rounded-md border border-input bg-background",
+          "ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
+          className,
+        )}
+        onClick={() => inputRef.current?.focus()}
+      >
+        {children}
+      </div>
+    </QueryInputContext>
+  );
+}
+
+interface IconProps {
+  children: ReactNode;
+}
+
+export function Icon({ children }: IconProps) {
+  return (
+    <div className="text-muted-foreground pointer-events-none absolute left-3 size-4 shrink-0">
+      {children}
+    </div>
+  );
+}
+
+interface InputProps {
+  placeholder?: string;
+  className?: string;
+}
+
+export function Input({ placeholder, className }: InputProps) {
+  const { value, segments, inputRef, handleChange, handleKeyDown, handleSelect } = useQueryInput();
+
+  return (
+    <>
+      {/* Colored backdrop */}
+      <div
+        aria-hidden
+        className="text-foreground pointer-events-none absolute inset-0 flex items-center overflow-hidden pr-3 pl-9 font-mono text-sm whitespace-pre"
+      >
+        {value === "" ? null : segments.map((seg, i) => renderSegment(seg, i))}
+      </div>
+
+      {/* Actual input */}
+      <input
+        ref={inputRef}
+        type="text"
+        value={value}
+        placeholder={placeholder}
+        onChange={handleChange}
+        onKeyDown={handleKeyDown}
+        onSelect={handleSelect}
+        className={cn(
+          "caret-foreground placeholder:text-muted-foreground relative w-full bg-transparent py-2 pr-3 pl-9 font-mono text-sm text-transparent outline-hidden placeholder:font-sans",
+          className,
+        )}
+        spellCheck={false}
+        autoComplete="off"
+      />
+    </>
+  );
+}
+
+export function Completions() {
+  const { suggestions, activeIndex, showDropdown, loading, selectSuggestion } = useQueryInput();
+
+  if (!showDropdown && !loading) return null;
+
+  return (
+    <div className="border-border bg-popover absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-md border shadow-md">
+      {loading && suggestions.length === 0 && (
+        <div className="text-muted-foreground px-3 py-2 text-sm">Loading…</div>
+      )}
+      {suggestions.map((s, i) => (
+        <button
+          key={`${s.value}-${s.label}`}
+          onMouseDown={(e) => {
+            e.preventDefault();
+            selectSuggestion(i);
+          }}
+          className={cn(
+            "flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm",
+            i === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted",
+          )}
+        >
+          {s.icon}
+          <span className="font-mono">{s.label}</span>
+          {s.description && (
+            <span className="text-muted-foreground ml-auto text-xs">{s.description}</span>
+          )}
+        </button>
+      ))}
+    </div>
+  );
+}

webui2/src/routes/$repo/_issues/issues/index.tsx πŸ”—

@@ -1,18 +1,19 @@
 import { useReadQuery } from "@apollo/client/react";
 import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
 import { formatDistanceToNow } from "date-fns";
-import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react";
-import { useState } from "react";
+import { CircleDot, CircleCheck, ChevronLeft, ChevronRight, Search } from "lucide-react";
+import { useMemo, useState } from "react";
 import * as v from "valibot";
 
 import { type BugListQuery, BugListDocument } from "@/__generated__/graphql";
 import { IssueFilters } from "@/components/bugs/IssueFilters";
+import type { SortValue } from "@/components/bugs/IssueFilters";
 import * as IssueRow from "@/components/bugs/IssueRow";
 import { LabelBadgeLink } from "@/components/bugs/LabelBadge";
-import type { SortValue } from "@/components/bugs/IssueFilters";
-import { QueryInput } from "@/components/bugs/QueryInput";
 import { Button } from "@/components/ui/button";
 import { ButtonLink } from "@/components/ui/button-link";
+import * as QueryInput from "@/components/ui/query-input";
+import type { CompletionProvider } from "@/components/ui/query-input";
 import { Skeleton } from "@/components/ui/skeleton";
 import { cn } from "@/lib/utils";
 
@@ -75,8 +76,58 @@ function RouteComponent() {
   const openCount = data?.repository?.openCount.totalCount ?? 0;
   const closedCount = data?.repository?.closedCount.totalCount ?? 0;
   const bugs = data?.repository?.bugs;
-  const validLabels = labelsData?.repository?.validLabels.nodes ?? [];
-  const allIdentities = identitiesData?.repository?.allIdentities.nodes ?? [];
+  const validLabels = labelsData?.repository?.validLabels.nodes;
+  const allIdentities = identitiesData?.repository?.allIdentities.nodes;
+
+  const completionProviders: CompletionProvider[] = useMemo(
+    () => [
+      {
+        prefix: "label:",
+        highlightClass: "text-yellow-600 dark:text-yellow-500",
+        getSuggestions: (query: string) =>
+          (validLabels ?? [])
+            .filter((l) => query === "" || l.name.toLowerCase().includes(query.toLowerCase()))
+            .slice(0, 8)
+            .map((l) => ({
+              value: l.name.includes(" ") ? `"${l.name}"` : l.name,
+              label: l.name,
+              icon: (
+                <span
+                  className="size-2 shrink-0 rounded-full"
+                  style={{
+                    backgroundColor: `rgb(${l.color.R},${l.color.G},${l.color.B})`,
+                  }}
+                />
+              ),
+            })),
+      },
+      {
+        prefix: "author:",
+        highlightClass: "text-blue-600 dark:text-blue-400",
+        getSuggestions: (query: string) =>
+          (allIdentities ?? [])
+            .filter(
+              (a) =>
+                query === "" ||
+                a.displayName.toLowerCase().includes(query.toLowerCase()) ||
+                (a.login ?? "").toLowerCase().includes(query.toLowerCase()) ||
+                (a.name ?? "").toLowerCase().includes(query.toLowerCase()),
+            )
+            .slice(0, 8)
+            .map((a) => {
+              const qv = a.login || a.name || a.humanId;
+              return {
+                value: qv.includes(" ") ? `"${qv}"` : qv,
+                label: a.displayName,
+                description:
+                  a.login && a.login !== a.displayName ? `@${a.login}` : undefined,
+              };
+            }),
+      },
+    ],
+    [validLabels, allIdentities],
+  );
+
   const totalCount = bugs?.totalCount ?? 0;
   const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
   const hasNext = bugs?.pageInfo.hasNextPage ?? false;
@@ -114,14 +165,16 @@ function RouteComponent() {
     <div>
       {/* Search bar */}
       <form onSubmit={handleSearch} className="mb-4 flex gap-2">
-        <QueryInput
+        <QueryInput.Root
           value={draft}
           onChange={setDraft}
           onSubmit={handleSearch}
-          placeholder="status:open author:… label:…"
-          labels={validLabels}
-          identities={allIdentities}
-        />
+          providers={completionProviders}
+        >
+          <QueryInput.Icon><Search /></QueryInput.Icon>
+          <QueryInput.Input placeholder="status:open author:… label:…" />
+          <QueryInput.Completions />
+        </QueryInput.Root>
         <Button type="submit">Search</Button>
       </form>
 
@@ -179,8 +232,8 @@ function RouteComponent() {
 
           <div className="ml-auto">
             <IssueFilters
-              labels={validLabels}
-              identities={allIdentities}
+              labels={validLabels ?? []}
+              identities={allIdentities ?? []}
               selectedLabels={selectedLabels}
               onLabelsChange={(labels) =>
                 applyFilters(statusFilter, labels, selectedAuthorQuery, parsed.freeText)