@assistant-ui/react
Version:
Open-source TypeScript/React library for building production-grade AI chat experiences
130 lines (113 loc) • 4.31 kB
text/typescript
import { useEffectEvent, useMemo, useState } from "react";
import { resource } from "@assistant-ui/tap";
import type {
Unstable_TriggerAdapter,
Unstable_TriggerCategory,
Unstable_TriggerItem,
} from "@assistant-ui/core";
import { matchesTriggerItemQuery } from "./matchesTriggerItemQuery";
export type TriggerNavigationResourceOutput = {
/** Filtered categories visible in the list (empty in search mode). */
readonly categories: readonly Unstable_TriggerCategory[];
/** Filtered items visible in the list. */
readonly items: readonly Unstable_TriggerItem[];
/** `true` when the current list is search results rather than categories. */
readonly isSearchMode: boolean;
/** Currently drilled-into category id (or `null` for the top level). */
readonly activeCategoryId: string | null;
/** Flat list used for keyboard navigation (categories or items). */
readonly navigableList: readonly (
| Unstable_TriggerCategory
| Unstable_TriggerItem
)[];
/** Drill into a category. */
selectCategory(categoryId: string): void;
/** Return to the top-level category list. */
goBack(): void;
};
/**
* Computes categories, items, search results, and navigation state from the
* adapter + current query. Pure derivation — no side effects on the composer.
*/
const useTriggerNavigationResource = ({
adapter,
query,
open,
}: {
adapter: Unstable_TriggerAdapter | undefined;
query: string;
open: boolean;
}): TriggerNavigationResourceOutput => {
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
const [wasOpen, setWasOpen] = useState(open);
if (wasOpen !== open) {
setWasOpen(open);
if (!open) setActiveCategoryId(null);
}
const categories = useMemo<readonly Unstable_TriggerCategory[]>(() => {
if (!open || !adapter) return [];
return adapter.categories();
}, [open, adapter]);
const effectiveActiveCategoryId = open ? activeCategoryId : null;
const allItems = useMemo<readonly Unstable_TriggerItem[]>(() => {
if (!effectiveActiveCategoryId || !adapter) return [];
return adapter.categoryItems(effectiveActiveCategoryId);
}, [effectiveActiveCategoryId, adapter]);
const searchResults = useMemo<readonly Unstable_TriggerItem[] | null>(() => {
if (!open || !adapter || effectiveActiveCategoryId) return null;
// If categories exist and query is empty, show categories first (not search)
if (!query && categories.length > 0) return null;
if (adapter.search) return adapter.search(query);
// fallback: no adapter.search
const all: Unstable_TriggerItem[] = [];
const lower = query.toLowerCase();
for (const cat of categories) {
for (const item of adapter.categoryItems(cat.id)) {
if (matchesTriggerItemQuery(item, lower)) {
all.push(item);
}
}
}
return all;
}, [open, adapter, query, effectiveActiveCategoryId, categories]);
const isSearchMode = searchResults !== null;
const filteredCategories = useMemo(() => {
if (isSearchMode) return [];
if (!query) return categories;
const lower = query.toLowerCase();
return categories.filter((cat) => cat.label.toLowerCase().includes(lower));
}, [categories, query, isSearchMode]);
const filteredItems = useMemo(() => {
if (isSearchMode) return searchResults ?? [];
if (!query) return allItems;
const lower = query.toLowerCase();
return allItems.filter((item) => matchesTriggerItemQuery(item, lower));
}, [allItems, query, isSearchMode, searchResults]);
const navigableList = useMemo(() => {
if (isSearchMode) return searchResults ?? [];
if (effectiveActiveCategoryId) return filteredItems;
return filteredCategories;
}, [
isSearchMode,
searchResults,
effectiveActiveCategoryId,
filteredItems,
filteredCategories,
]);
const selectCategory = useEffectEvent((categoryId: string) => {
setActiveCategoryId(categoryId);
});
const goBack = useEffectEvent(() => {
setActiveCategoryId(null);
});
return {
categories: filteredCategories,
items: filteredItems,
isSearchMode,
activeCategoryId: effectiveActiveCategoryId,
navigableList,
selectCategory,
goBack,
};
};
export const TriggerNavigationResource = resource(useTriggerNavigationResource);