{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-assistant",
  "type": "registry:ui",
  "title": "FilterAssistant",
  "description": "Natural-language filtering with a review step: useFilterAssistant sends the prompt, current filter, a JSON Schema of your fields, time context and an AbortSignal to your own resolve callback (any provider), sanitizes the answer against the field allowlist, and turns it into a proposal. FilterAssistant (prompt, stop, status, actions slot for VoiceInput) and FilterProposal (added/changed/removed chips with Accept/Reject, needs-input chips, what couldn't be used, one-tap clarification choices, optional result-count preview, Apply/Apply all/Discard). Nothing reaches the filter until applied, and applying is one undo step. Model text renders as text only.",
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [
    "https://components.dethink.co.uk/r/dethink-base.json",
    "https://components.dethink.co.uk/r/filter-core.json",
    "https://components.dethink.co.uk/r/filter-bar.json",
    "https://components.dethink.co.uk/r/input.json",
    "https://components.dethink.co.uk/r/live-region.json"
  ],
  "files": [
    {
      "path": "packages/components/src/components/filter-builder/use-filter-assistant.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/filter-builder/use-filter-assistant.ts",
      "content": "import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n  applyFilterProposalChanges,\n  createFilterId,\n  getFilterFieldSummaries,\n  getFilterProposalChanges,\n  getFilterConditions,\n  isFilterConditionActive,\n  matchFilterProposalIds,\n  sanitizeFilterAssistantResult,\n  toFilterJsonSchema,\n  type FilterAssistantResult,\n  type FilterClarification,\n  type FilterFieldSummary,\n  type FilterJsonSchema,\n  type FilterProposalChange,\n  type FilterUnresolved,\n} from \"./filter-core\";\nimport type {\n  Filter,\n  FilterEvaluateOptions,\n  FilterFields,\n} from \"./filter-types\";\nimport type { FilterState } from \"./use-filter-state\";\n\nexport interface FilterAssistantAnswer {\n  question: string;\n  answer: string;\n}\n\n/** What your `resolve` callback receives. Send it to any model. */\nexport interface FilterAssistantRequest {\n  prompt: string;\n  /** The filter the person has now. */\n  current: Filter;\n  /** Structured-output schema for the answer. */\n  schema: FilterJsonSchema;\n  fields: FilterFieldSummary[];\n  /** Answers to the model's clarifying questions, oldest first. */\n  answers: FilterAssistantAnswer[];\n  /** Epoch ms and IANA zone, for relative dates (\"last week\"). */\n  now: number;\n  timeZone: string;\n  signal: AbortSignal;\n}\n\nexport type FilterAssistantResolve = (\n  request: FilterAssistantRequest,\n) => Promise<FilterAssistantResult | false>;\n\nexport type FilterAssistantStatus = \"idle\" | \"loading\" | \"ready\" | \"error\";\n\nexport type FilterProposalDecision = \"accepted\" | \"rejected\";\n\nexport interface FilterAssistantProposal {\n  id: string;\n  prompt: string;\n  /** The filter the proposal was made against. */\n  base: Filter;\n  /** The proposed filter, sharing ids with `base` where nothing changed. */\n  filter?: Filter;\n  changes: FilterProposalChange[];\n  clarifications: FilterClarification[];\n  unresolved: FilterUnresolved[];\n  message?: string;\n}\n\nexport type FilterAssistantErrorCode = \"declined\" | \"failed\";\n\nexport interface UseFilterAssistantOptions<TData = unknown> {\n  fields: FilterFields<TData>;\n  state: FilterState;\n  resolve: FilterAssistantResolve;\n  /** `now` and `timeZone` passed to the model. */\n  evaluateOptions?: Pick<FilterEvaluateOptions, \"now\" | \"timeZone\">;\n  maxDepth?: number;\n  /**\n   * Counts rows for the filter the selected changes would give, e.g. from\n   * your API. Shown next to Apply.\n   */\n  onPreviewCount?: (filter: Filter, signal: AbortSignal) => Promise<number>;\n  /** Called with errors thrown by `resolve` (never shown to the person). */\n  onError?: (error: unknown) => void;\n}\n\nexport interface FilterAssistantState {\n  fields: FilterFields;\n  prompt: string;\n  setPrompt: (prompt: string) => void;\n  status: FilterAssistantStatus;\n  errorCode?: FilterAssistantErrorCode;\n  proposal?: FilterAssistantProposal;\n  decisions: Readonly<Record<string, FilterProposalDecision>>;\n  setDecision: (changeId: string, decision: FilterProposalDecision) => void;\n  /** Accepted changes, in order. */\n  selected: FilterProposalChange[];\n  previewCount?: number;\n  previewStatus: \"idle\" | \"loading\" | \"error\";\n  /** Asks the model. Defaults to the current prompt. */\n  submit: (prompt?: string) => void;\n  /** Cancels a running request. */\n  abort: () => void;\n  /** Answers a clarifying question and asks again. */\n  answer: (clarificationId: string, choiceId: string) => void;\n  /** Applies accepted changes (or all) as one undo step. */\n  apply: (options?: { all?: boolean }) => void;\n  discard: () => void;\n}\n\n/**\n * Natural-language filtering with a review step. `resolve` sends the\n * request to any model; its answer is sanitized against the fields and\n * shown as a proposal. Nothing reaches the filter until the person applies\n * it, and applying is a single undo step.\n */\nexport function useFilterAssistant<TData>({\n  evaluateOptions,\n  fields,\n  maxDepth,\n  onError,\n  onPreviewCount,\n  resolve,\n  state,\n}: UseFilterAssistantOptions<TData>): FilterAssistantState {\n  const schema = fields as FilterFields;\n  const [prompt, setPrompt] = useState(\"\");\n  const [status, setStatus] = useState<FilterAssistantStatus>(\"idle\");\n  const [errorCode, setErrorCode] = useState<FilterAssistantErrorCode>();\n  const [proposal, setProposal] = useState<FilterAssistantProposal>();\n  const [answers, setAnswers] = useState<FilterAssistantAnswer[]>([]);\n  const [decisions, setDecisions] = useState<\n    Record<string, FilterProposalDecision>\n  >({});\n  const [preview, setPreview] = useState<{\n    count?: number;\n    status: FilterAssistantState[\"previewStatus\"];\n  }>({ status: \"idle\" });\n  const controllerRef = useRef<AbortController | null>(null);\n  const latest = useRef({\n    evaluateOptions,\n    maxDepth,\n    onError,\n    onPreviewCount,\n    resolve,\n    state,\n  });\n\n  latest.current = {\n    evaluateOptions,\n    maxDepth,\n    onError,\n    onPreviewCount,\n    resolve,\n    state,\n  };\n\n  const abort = useCallback(() => {\n    controllerRef.current?.abort();\n    controllerRef.current = null;\n    setStatus((current) => (current === \"loading\" ? \"idle\" : current));\n  }, []);\n\n  useEffect(() => () => controllerRef.current?.abort(), []);\n\n  const request = useCallback(\n    (text: string, withAnswers: FilterAssistantAnswer[]) => {\n      const trimmed = text.trim();\n\n      if (!trimmed) {\n        return;\n      }\n\n      controllerRef.current?.abort();\n\n      const controller = new AbortController();\n      const options = latest.current;\n      const now =\n        options.evaluateOptions?.now === undefined\n          ? Date.now()\n          : new Date(options.evaluateOptions.now).getTime();\n\n      // The filter the model sees; its answer is diffed against this, so\n      // chips added while it thinks aren't proposed for removal.\n      const requestFilter = options.state.filter;\n\n      controllerRef.current = controller;\n      setStatus(\"loading\");\n      setErrorCode(undefined);\n\n      // Runs `update` for the current request only; true if it ran.\n      const settle = (update: () => void) => {\n        if (controllerRef.current !== controller || controller.signal.aborted) {\n          return false;\n        }\n\n        controllerRef.current = null;\n\n        // Whatever the answer holds, never leave the assistant loading.\n        try {\n          update();\n        } catch (error) {\n          setStatus(\"error\");\n          setErrorCode(\"failed\");\n          latest.current.onError?.(error);\n        }\n\n        return true;\n      };\n\n      let pending: Promise<FilterAssistantResult | false>;\n\n      try {\n        pending = Promise.resolve(\n          options.resolve({\n            prompt: trimmed,\n            current: requestFilter,\n            schema: toFilterJsonSchema(schema, { maxDepth: options.maxDepth }),\n            fields: getFilterFieldSummaries(schema),\n            answers: withAnswers,\n            now,\n            timeZone: options.evaluateOptions?.timeZone ?? \"UTC\",\n            signal: controller.signal,\n          }),\n        );\n      } catch (error) {\n        pending = Promise.reject(error);\n      }\n\n      pending.then(\n        (result) => {\n          settle(() => {\n            if (result === false) {\n              setStatus(\"error\");\n              setErrorCode(\"declined\");\n              return;\n            }\n\n            const clean = sanitizeFilterAssistantResult(result, schema, {\n              maxDepth: latest.current.maxDepth,\n            });\n            const base = requestFilter;\n            const filter = clean.filter\n              ? matchFilterProposalIds(clean.filter, base)\n              : undefined;\n            // Derived after ids are matched: conditions whose value is\n            // missing or unusable.\n            const needsInput = filter\n              ? getFilterConditions(filter)\n                  .filter(\n                    (condition) => !isFilterConditionActive(condition, schema),\n                  )\n                  .map((condition) => condition.id)\n              : [];\n            const changes = filter\n              ? getFilterProposalChanges(base, filter, needsInput)\n              : [];\n\n            setProposal({\n              id: createFilterId(\"proposal\"),\n              prompt: trimmed,\n              base,\n              filter,\n              changes,\n              clarifications: clean.clarifications,\n              unresolved: clean.unresolved,\n              ...(clean.message ? { message: clean.message } : {}),\n            });\n            setDecisions(\n              Object.fromEntries(\n                changes.map((change) => [change.id, \"accepted\" as const]),\n              ),\n            );\n            setStatus(\"ready\");\n          });\n        },\n        (error: unknown) => {\n          if (controller.signal.aborted) {\n            return;\n          }\n\n          const settled = settle(() => {\n            setStatus(\"error\");\n            setErrorCode(\"failed\");\n          });\n\n          // Outside settle, so a throwing onError is reported once.\n          if (settled) {\n            latest.current.onError?.(error);\n          }\n        },\n      );\n    },\n    [schema],\n  );\n\n  const selected = useMemo(\n    () =>\n      proposal?.changes.filter(\n        (change) => decisions[change.id] !== \"rejected\",\n      ) ?? [],\n    [decisions, proposal],\n  );\n\n  // Optional row count for the selected changes. The callback is read\n  // through a ref, so an inline function doesn't restart counting (or loop).\n  const hasPreview = onPreviewCount !== undefined;\n\n  useEffect(() => {\n    const count = latest.current.onPreviewCount;\n\n    if (!hasPreview || !count || !proposal?.filter) {\n      setPreview((current) =>\n        current.status === \"idle\" && current.count === undefined\n          ? current\n          : { status: \"idle\" },\n      );\n      return undefined;\n    }\n\n    const controller = new AbortController();\n    // Counted on the live filter, like Apply.\n    const filter = applyFilterProposalChanges(\n      state.filter,\n      proposal.filter,\n      selected,\n    );\n\n    setPreview((current) =>\n      current.status === \"loading\"\n        ? current\n        : { ...current, status: \"loading\" },\n    );\n    count(filter, controller.signal).then(\n      (result) => {\n        if (!controller.signal.aborted) {\n          setPreview({ count: result, status: \"idle\" });\n        }\n      },\n      () => {\n        if (!controller.signal.aborted) {\n          setPreview({ status: \"error\" });\n        }\n      },\n    );\n\n    return () => {\n      controller.abort();\n    };\n  }, [hasPreview, proposal, selected, state.filter]);\n\n  const clear = () => {\n    // A request still running (e.g. after a clarification) must not bring\n    // the proposal back.\n    controllerRef.current?.abort();\n    controllerRef.current = null;\n    setProposal(undefined);\n    setDecisions({});\n    setAnswers([]);\n    setStatus(\"idle\");\n  };\n\n  return {\n    fields: schema,\n    prompt,\n    setPrompt,\n    status,\n    ...(errorCode ? { errorCode } : {}),\n    ...(proposal ? { proposal } : {}),\n    decisions,\n    setDecision: (changeId, decision) => {\n      setDecisions((current) => ({ ...current, [changeId]: decision }));\n    },\n    selected,\n    ...(preview.count === undefined ? {} : { previewCount: preview.count }),\n    previewStatus: preview.status,\n    submit: (text = prompt) => {\n      setAnswers([]);\n      setProposal(undefined);\n      request(text, []);\n    },\n    abort,\n    answer: (clarificationId, choiceId) => {\n      const clarification = proposal?.clarifications.find(\n        (item) => item.id === clarificationId,\n      );\n      const choice = clarification?.choices.find(\n        (item) => item.id === choiceId,\n      );\n\n      if (!proposal || !clarification || !choice) {\n        return;\n      }\n\n      const next = [\n        ...answers,\n        { question: clarification.question, answer: choice.label },\n      ];\n\n      setAnswers(next);\n      request(proposal.prompt, next);\n    },\n    apply: ({ all = false } = {}) => {\n      if (!proposal?.filter || status === \"loading\") {\n        return;\n      }\n\n      const chosen = all ? proposal.changes : selected;\n      const current = state.filter;\n      const next = applyFilterProposalChanges(current, proposal.filter, chosen);\n\n      if (next !== current) {\n        state.setFilter(next);\n      }\n\n      clear();\n      setPrompt(\"\");\n    },\n    discard: clear,\n  };\n}\n"
    },
    {
      "path": "packages/components/src/components/filter-builder/filter-assistant-parts.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/filter-builder/filter-assistant-parts.tsx",
      "content": "import { cn } from \"../../utils/cn\";\nimport type {\n  FilterProposalChangeKind,\n  FilterUnresolvedReason,\n} from \"./filter-core\";\n\nexport interface FilterAssistantLabels {\n  /** Label of the prompt box. */\n  prompt: string;\n  placeholder: string;\n  submit: string;\n  stop: string;\n  loading: string;\n  declined: string;\n  failed: string;\n  /** Heading of the proposal. */\n  proposal: string;\n  proposed: (count: number) => string;\n  noChanges: string;\n  needsChoice: string;\n  kind: Record<FilterProposalChangeKind, string>;\n  was: string;\n  needsInput: string;\n  accept: string;\n  reject: string;\n  /** Accessible name of a change's decision buttons. */\n  decision: (sentence: string) => string;\n  unresolved: string;\n  unresolvedReason: Record<FilterUnresolvedReason, string>;\n  applySelected: (count: number) => string;\n  applyAll: string;\n  discard: string;\n  previewCount: (count: number) => string;\n  previewLoading: string;\n}\n\nexport const defaultFilterAssistantLabels: FilterAssistantLabels = {\n  prompt: \"Describe the filter you want\",\n  placeholder: \"e.g. open bugs from last week\",\n  submit: \"Propose\",\n  stop: \"Stop\",\n  loading: \"Working on a proposal…\",\n  declined: \"The assistant couldn't turn that into a filter. Try rephrasing.\",\n  failed: \"Something went wrong. Try again.\",\n  proposal: \"Proposed changes\",\n  proposed: (count) =>\n    `${count} ${count === 1 ? \"change\" : \"changes\"} proposed`,\n  noChanges: \"No filter changes suggested.\",\n  needsChoice: \"The assistant needs a choice to continue.\",\n  kind: { added: \"Add\", removed: \"Remove\", changed: \"Change\" },\n  was: \"was\",\n  needsInput: \"Needs a value\",\n  accept: \"Accept\",\n  reject: \"Reject\",\n  decision: (sentence) => `Keep this change? ${sentence}`,\n  unresolved: \"Couldn't use\",\n  unresolvedReason: {\n    \"unknown-field\": \"no such field\",\n    \"unknown-operator\": \"not a supported comparison\",\n    \"unknown-value\": \"no matching value\",\n    \"max-depth\": \"nested too deeply\",\n    \"too-many-conditions\": \"too many conditions\",\n    model: \"not supported\",\n  },\n  applySelected: (count) =>\n    `Apply ${count} ${count === 1 ? \"change\" : \"changes\"}`,\n  applyAll: \"Apply all\",\n  discard: \"Discard\",\n  previewCount: (count) =>\n    `About ${count.toLocaleString(\"en-US\")} ${count === 1 ? \"result\" : \"results\"}`,\n  previewLoading: \"Counting results…\",\n};\n\nexport const filterAssistantClasses =\n  \"grid w-full min-w-0 gap-[var(--dt-space-2)]\";\n\nexport const filterAssistantFormClasses =\n  \"flex min-w-0 items-center gap-[var(--dt-space-2)]\";\n\nexport const filterAssistantStatusClasses =\n  \"min-h-5 text-sm text-muted-foreground data-[tone=error]:text-destructive\";\n\nexport const filterProposalClasses =\n  \"grid gap-[var(--dt-space-3)] rounded-lg border border-border bg-background p-[var(--dt-space-3)] outline-none focus-visible:ring-2 focus-visible:ring-ring\";\n\nexport const filterProposalHeadingClasses = \"text-sm font-medium\";\n\nexport const filterProposalListClasses = \"grid gap-[var(--dt-space-1-5)]\";\n\nexport const filterProposalChangeClasses =\n  \"grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-[var(--dt-space-2)] gap-y-[var(--dt-space-1-5)] rounded-md border border-l-4 px-[var(--dt-space-2-5)] py-[var(--dt-space-2)] text-sm motion-safe:transition-[opacity,background-color] motion-safe:duration-[var(--dt-motion-fast)] data-[proposal=added]:border-l-success data-[proposal=changed]:border-l-info data-[proposal=removed]:border-l-destructive data-[decision=rejected]:opacity-60 sm:grid-cols-[auto_minmax(0,1fr)_auto]\";\n\nexport const filterProposalKindClasses =\n  \"inline-flex items-center gap-[var(--dt-space-1)] rounded-sm bg-muted px-[var(--dt-space-1-5)] py-0.5 text-xs font-medium text-foreground\";\n\nexport const filterProposalSentenceClasses =\n  \"min-w-0 [overflow-wrap:anywhere] group-data-[proposal=removed]/change:line-through\";\n\nexport const filterProposalMetaClasses = \"text-xs text-muted-foreground\";\n\nexport const filterProposalNeedsInputClasses =\n  \"ms-[var(--dt-space-1-5)] inline-flex items-center rounded-sm border border-warning px-[var(--dt-space-1)] text-xs font-medium text-foreground\";\n\nexport const filterProposalDecisionClasses =\n  \"col-span-2 flex gap-[var(--dt-space-1)] sm:col-span-1\";\n\nexport const filterProposalToggleClasses =\n  \"inline-flex h-7 items-center rounded-md border border-border px-[var(--dt-space-2)] text-xs font-medium text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-pressed:border-foreground aria-pressed:bg-muted aria-pressed:text-foreground motion-safe:transition-colors motion-safe:duration-[var(--dt-motion-fast)]\";\n\nexport const filterProposalButtonClasses =\n  \"inline-flex h-8 items-center justify-center rounded-md px-[var(--dt-space-3)] text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 motion-safe:transition-colors motion-safe:duration-[var(--dt-motion-fast)]\";\n\nexport const filterProposalPrimaryClasses = cn(\n  filterProposalButtonClasses,\n  \"bg-primary text-primary-foreground hover:bg-primary/90\",\n);\n\nexport const filterProposalSecondaryClasses = cn(\n  filterProposalButtonClasses,\n  \"border border-border text-foreground hover:bg-muted\",\n);\n\nexport const filterProposalGhostClasses = cn(\n  filterProposalButtonClasses,\n  \"text-muted-foreground hover:bg-muted hover:text-foreground\",\n);\n\nexport function ProposalKindIcon({ kind }: { kind: FilterProposalChangeKind }) {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-3\">\n      <path\n        d={\n          kind === \"added\"\n            ? \"M8 3.5v9M3.5 8h9\"\n            : kind === \"removed\"\n              ? \"M3.5 8h9\"\n              : \"M3 6h10M3 10h10\"\n        }\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeWidth=\"1.75\"\n      />\n    </svg>\n  );\n}\n\nexport function SparkIcon() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-4 shrink-0\">\n      <path\n        d=\"M8 2.5 9.3 6.7 13.5 8 9.3 9.3 8 13.5 6.7 9.3 2.5 8l4.2-1.3z\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.4\"\n      />\n    </svg>\n  );\n}\n"
    },
    {
      "path": "packages/components/src/components/filter-builder/filter-proposal.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/filter-builder/filter-proposal.tsx",
      "content": "import { forwardRef, useId, useMemo, type HTMLAttributes } from \"react\";\nimport { cn } from \"../../utils/cn\";\nimport {\n  describeFilter,\n  describeFilterCondition,\n  type DescribeFilterOptions,\n  type FilterProposalChange,\n} from \"./filter-core\";\nimport {\n  ProposalKindIcon,\n  defaultFilterAssistantLabels,\n  filterProposalChangeClasses,\n  filterProposalClasses,\n  filterProposalDecisionClasses,\n  filterProposalGhostClasses,\n  filterProposalHeadingClasses,\n  filterProposalKindClasses,\n  filterProposalListClasses,\n  filterProposalMetaClasses,\n  filterProposalNeedsInputClasses,\n  filterProposalPrimaryClasses,\n  filterProposalSecondaryClasses,\n  filterProposalSentenceClasses,\n  filterProposalToggleClasses,\n  type FilterAssistantLabels,\n} from \"./filter-assistant-parts\";\nimport type { FilterFields, FilterNode } from \"./filter-types\";\nimport type { FilterAssistantState } from \"./use-filter-assistant\";\n\nexport interface FilterProposalProps extends Omit<\n  HTMLAttributes<HTMLElement>,\n  \"children\"\n> {\n  /** State from `useFilterAssistant`. */\n  assistant: FilterAssistantState;\n  labels?: Partial<FilterAssistantLabels>;\n  describeOptions?: DescribeFilterOptions;\n  /** Called after Apply, Apply all or Discard, e.g. to move focus. */\n  onSettled?: () => void;\n  /** Called after a clarification choice is sent. */\n  onAnswer?: () => void;\n}\n\nfunction sentence(\n  node: FilterNode,\n  fields: FilterFields,\n  options?: DescribeFilterOptions,\n) {\n  return node.type === \"condition\"\n    ? describeFilterCondition(node, fields, options)\n    : describeFilter(node, fields, options);\n}\n\n/**\n * Review of an assistant's proposal: each added, changed or removed chip\n * with Accept and Reject, clarification choices, what couldn't be used, and\n * Apply (selected), Apply all and Discard. Model text renders as text only.\n */\nexport const FilterProposal = forwardRef<HTMLElement, FilterProposalProps>(\n  (\n    {\n      assistant,\n      className,\n      describeOptions,\n      labels: labelOverrides,\n      onAnswer,\n      onSettled,\n      ...props\n    },\n    ref,\n  ) => {\n    const labels = useMemo(\n      () => ({ ...defaultFilterAssistantLabels, ...labelOverrides }),\n      [labelOverrides],\n    );\n    const headingId = useId();\n    const { decisions, fields, proposal, selected } = assistant;\n\n    if (!proposal) {\n      return null;\n    }\n\n    const { changes, clarifications, unresolved } = proposal;\n    const describe = (change: FilterProposalChange) =>\n      sentence(change.node, fields, describeOptions);\n\n    return (\n      <section\n        {...props}\n        ref={ref}\n        tabIndex={-1}\n        aria-labelledby={headingId}\n        data-slot=\"filter-proposal\"\n        className={cn(filterProposalClasses, className)}\n      >\n        <h3 id={headingId} className={filterProposalHeadingClasses}>\n          {labels.proposal}\n        </h3>\n        {proposal.message ? (\n          <p data-slot=\"filter-proposal-message\" className=\"text-sm\">\n            {proposal.message}\n          </p>\n        ) : null}\n        {clarifications.map((clarification) => {\n          const questionId = `${headingId}-${clarification.id}`;\n\n          return (\n            <div\n              key={clarification.id}\n              role=\"group\"\n              aria-labelledby={questionId}\n              data-slot=\"filter-proposal-clarification\"\n              className=\"grid gap-[var(--dt-space-1-5)]\"\n            >\n              <p id={questionId} className=\"text-sm font-medium\">\n                {clarification.question}\n              </p>\n              <div className=\"flex flex-wrap gap-[var(--dt-space-1-5)]\">\n                {clarification.choices.map((choice) => (\n                  <button\n                    key={choice.id}\n                    type=\"button\"\n                    disabled={assistant.status === \"loading\"}\n                    className={filterProposalSecondaryClasses}\n                    onClick={() => {\n                      assistant.answer(clarification.id, choice.id);\n                      onAnswer?.();\n                    }}\n                  >\n                    {choice.label}\n                  </button>\n                ))}\n              </div>\n            </div>\n          );\n        })}\n        {unresolved.length > 0 ? (\n          <div data-slot=\"filter-proposal-unresolved\" className=\"grid gap-1\">\n            <p className=\"text-sm font-medium\">{labels.unresolved}</p>\n            <ul className={cn(filterProposalMetaClasses, \"grid gap-0.5\")}>\n              {unresolved.map((item) => (\n                <li key={`${item.reason}:${item.text}`}>\n                  {item.text} ({labels.unresolvedReason[item.reason]})\n                </li>\n              ))}\n            </ul>\n          </div>\n        ) : null}\n        {changes.length === 0 && clarifications.length === 0 ? (\n          <p className={filterProposalMetaClasses}>{labels.noChanges}</p>\n        ) : null}\n        {changes.length > 0 ? (\n          <ul className={filterProposalListClasses}>\n            {changes.map((change) => {\n              const text = describe(change);\n              const decision = decisions[change.id] ?? \"accepted\";\n\n              return (\n                <li\n                  key={change.id}\n                  data-slot=\"filter-proposal-change\"\n                  data-proposal={change.kind}\n                  data-decision={decision}\n                  data-needs-input={change.needsInput ? \"\" : undefined}\n                  className={cn(\"group/change\", filterProposalChangeClasses)}\n                >\n                  <span className={filterProposalKindClasses}>\n                    <ProposalKindIcon kind={change.kind} />\n                    {labels.kind[change.kind]}\n                  </span>\n                  <span className=\"min-w-0\">\n                    <span className={filterProposalSentenceClasses}>\n                      {text}\n                    </span>\n                    {change.needsInput ? (\n                      <span className={filterProposalNeedsInputClasses}>\n                        {labels.needsInput}\n                      </span>\n                    ) : null}\n                    {change.before ? (\n                      <span className={cn(filterProposalMetaClasses, \"block\")}>\n                        {labels.was}{\" \"}\n                        {sentence(change.before, fields, describeOptions)}\n                      </span>\n                    ) : null}\n                  </span>\n                  <span\n                    role=\"group\"\n                    aria-label={labels.decision(text)}\n                    className={filterProposalDecisionClasses}\n                  >\n                    <button\n                      type=\"button\"\n                      aria-pressed={decision === \"accepted\"}\n                      className={filterProposalToggleClasses}\n                      onClick={() => {\n                        assistant.setDecision(change.id, \"accepted\");\n                      }}\n                    >\n                      {labels.accept}\n                    </button>\n                    <button\n                      type=\"button\"\n                      aria-pressed={decision === \"rejected\"}\n                      className={filterProposalToggleClasses}\n                      onClick={() => {\n                        assistant.setDecision(change.id, \"rejected\");\n                      }}\n                    >\n                      {labels.reject}\n                    </button>\n                  </span>\n                </li>\n              );\n            })}\n          </ul>\n        ) : null}\n        <div className=\"flex flex-wrap items-center gap-[var(--dt-space-2)]\">\n          {changes.length > 0 ? (\n            <>\n              <button\n                type=\"button\"\n                disabled={\n                  selected.length === 0 || assistant.status === \"loading\"\n                }\n                className={filterProposalPrimaryClasses}\n                onClick={() => {\n                  assistant.apply();\n                  onSettled?.();\n                }}\n              >\n                {labels.applySelected(selected.length)}\n              </button>\n              {selected.length < changes.length ? (\n                <button\n                  type=\"button\"\n                  disabled={assistant.status === \"loading\"}\n                  className={filterProposalSecondaryClasses}\n                  onClick={() => {\n                    assistant.apply({ all: true });\n                    onSettled?.();\n                  }}\n                >\n                  {labels.applyAll}\n                </button>\n              ) : null}\n            </>\n          ) : null}\n          <button\n            type=\"button\"\n            className={filterProposalGhostClasses}\n            onClick={() => {\n              assistant.discard();\n              onSettled?.();\n            }}\n          >\n            {labels.discard}\n          </button>\n          {assistant.previewStatus === \"loading\" ? (\n            <span className={filterProposalMetaClasses}>\n              {labels.previewLoading}\n            </span>\n          ) : assistant.previewCount === undefined ? null : (\n            <span\n              data-slot=\"filter-proposal-preview\"\n              className={filterProposalMetaClasses}\n            >\n              {labels.previewCount(assistant.previewCount)}\n            </span>\n          )}\n        </div>\n      </section>\n    );\n  },\n);\n\nFilterProposal.displayName = \"FilterProposal\";\n"
    },
    {
      "path": "packages/components/src/components/filter-builder/filter-assistant.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/filter-builder/filter-assistant.tsx",
      "content": "import {\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  type HTMLAttributes,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"../../utils/cn\";\nimport { Input } from \"../input\";\nimport { LiveRegion } from \"../live-region\";\nimport type { DescribeFilterOptions } from \"./filter-core\";\nimport {\n  SparkIcon,\n  defaultFilterAssistantLabels,\n  filterAssistantClasses,\n  filterAssistantFormClasses,\n  filterAssistantStatusClasses,\n  filterProposalPrimaryClasses,\n  filterProposalSecondaryClasses,\n  type FilterAssistantLabels,\n} from \"./filter-assistant-parts\";\nimport { FilterProposal } from \"./filter-proposal\";\nimport type { FilterAssistantState } from \"./use-filter-assistant\";\n\nexport interface FilterAssistantProps extends Omit<\n  HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> {\n  /** State from `useFilterAssistant`. */\n  assistant: FilterAssistantState;\n  labels?: Partial<FilterAssistantLabels>;\n  /**\n   * Extra controls beside the prompt, e.g. a VoiceInput whose transcript\n   * you pass to `assistant.setPrompt` or `assistant.submit`.\n   */\n  actions?: ReactNode;\n  /** Renders the proposal under the prompt. Defaults to true. */\n  showProposal?: boolean;\n  describeOptions?: DescribeFilterOptions;\n}\n\n/**\n * Ask for a filter in words. The assistant's answer appears as a proposal\n * to review; nothing changes until it is applied.\n */\nexport function FilterAssistant({\n  actions,\n  assistant,\n  className,\n  describeOptions,\n  labels: labelOverrides,\n  showProposal = true,\n  ...props\n}: FilterAssistantProps) {\n  const labels = useMemo(\n    () => ({ ...defaultFilterAssistantLabels, ...labelOverrides }),\n    [labelOverrides],\n  );\n  const inputId = useId();\n  const statusId = useId();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const proposalRef = useRef<HTMLElement>(null);\n  const focusProposalRef = useRef(false);\n  const stopFocusedRef = useRef(false);\n  const { proposal, status } = assistant;\n  const loading = status === \"loading\";\n\n  // A request that ends while Stop has focus removes Stop; keep focus in\n  // the prompt instead of losing it.\n  useEffect(() => {\n    if (!loading && stopFocusedRef.current) {\n      stopFocusedRef.current = false;\n\n      if (\n        document.activeElement === null ||\n        document.activeElement === document.body\n      ) {\n        inputRef.current?.focus();\n      }\n    }\n  }, [loading]);\n\n  // After a clarification answer, move focus to the new proposal.\n  useEffect(() => {\n    if (focusProposalRef.current && proposal && !loading) {\n      focusProposalRef.current = false;\n      proposalRef.current?.focus();\n    }\n  }, [loading, proposal]);\n\n  const statusText = loading\n    ? labels.loading\n    : status === \"error\"\n      ? assistant.errorCode === \"declined\"\n        ? labels.declined\n        : labels.failed\n      : proposal\n        ? proposal.changes.length > 0\n          ? labels.proposed(proposal.changes.length)\n          : proposal.clarifications.length > 0\n            ? labels.needsChoice\n            : labels.noChanges\n        : \"\";\n\n  return (\n    <div\n      {...props}\n      data-slot=\"filter-assistant\"\n      data-status={status}\n      className={cn(filterAssistantClasses, className)}\n    >\n      <form\n        className={filterAssistantFormClasses}\n        onSubmit={(event) => {\n          event.preventDefault();\n          assistant.submit();\n          // Propose turns into Stop (and back); keep focus in the prompt.\n          inputRef.current?.focus();\n        }}\n      >\n        <label htmlFor={inputId} className=\"sr-only\">\n          {labels.prompt}\n        </label>\n        <Input\n          ref={inputRef}\n          id={inputId}\n          value={assistant.prompt}\n          placeholder={labels.placeholder}\n          aria-describedby={statusText ? statusId : undefined}\n          autoComplete=\"off\"\n          className=\"min-w-0 flex-1\"\n          onChange={(event) => {\n            assistant.setPrompt(event.currentTarget.value);\n          }}\n          onKeyDown={(event) => {\n            if (event.key === \"Escape\" && loading) {\n              event.preventDefault();\n              assistant.abort();\n            }\n          }}\n        />\n        {actions}\n        {loading ? (\n          // Distinct keys: reusing one element would flip it back to a\n          // submit button mid-click and submit the form again.\n          <button\n            key=\"stop\"\n            type=\"button\"\n            onFocus={() => {\n              stopFocusedRef.current = true;\n            }}\n            className={filterProposalSecondaryClasses}\n            onClick={(event) => {\n              event.preventDefault();\n              assistant.abort();\n              inputRef.current?.focus();\n            }}\n          >\n            {labels.stop}\n          </button>\n        ) : (\n          <button\n            key=\"submit\"\n            type=\"submit\"\n            disabled={assistant.prompt.trim() === \"\"}\n            className={cn(filterProposalPrimaryClasses, \"gap-1.5\")}\n          >\n            <SparkIcon />\n            {labels.submit}\n          </button>\n        )}\n      </form>\n      <p\n        id={statusId}\n        data-slot=\"filter-assistant-status\"\n        data-tone={status === \"error\" ? \"error\" : undefined}\n        className={filterAssistantStatusClasses}\n      >\n        {statusText}\n      </p>\n      <LiveRegion slotName=\"filter-assistant-announcer\">\n        {statusText}\n      </LiveRegion>\n      {showProposal && proposal ? (\n        <FilterProposal\n          ref={proposalRef}\n          assistant={assistant}\n          labels={labelOverrides}\n          describeOptions={describeOptions}\n          onAnswer={() => {\n            focusProposalRef.current = true;\n          }}\n          onSettled={() => {\n            inputRef.current?.focus();\n          }}\n        />\n      ) : null}\n    </div>\n  );\n}\n"
    },
    {
      "path": "packages/components/src/utils/cn.ts",
      "type": "registry:file",
      "target": "~/components/dethink/utils/cn.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
    }
  ]
}
