@assistant-ui/react
Version:
Open-source TypeScript/React library for building production-grade AI chat experiences
143 lines (129 loc) • 5.06 kB
text/typescript
"use client";
import { useMemo, useRef } from "react";
import { useIsomorphicLayoutEffect } from "../utils/useIsomorphicLayoutEffect";
import type {
Unstable_TriggerAdapter,
Unstable_TriggerItem,
} from "@assistant-ui/core";
import type { Unstable_IconComponent } from "./useMentionAdapter";
import { matchesTriggerItemQuery } from "../primitives/composer/trigger/matchesTriggerItemQuery";
/** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */
export type Unstable_SlashCommand = {
readonly id: string;
readonly label?: string | undefined;
readonly description?: string | undefined;
readonly icon?: string | undefined;
readonly execute: () => void;
};
/** @deprecated Experimental since 2026-04-06. Not scheduled for removal; the API may change in any release. */
export type Unstable_UseSlashCommandAdapterOptions = {
readonly commands: readonly Unstable_SlashCommand[];
/** Strip the trigger text from the composer after executing. @default false */
readonly removeOnExecute?: boolean | undefined;
/** Maps `metadata.icon` / `category.id` string keys to React components. */
readonly iconMap?: Record<string, Unstable_IconComponent>;
/** Fallback icon when no entry in `iconMap` matches. */
readonly fallbackIcon?: Unstable_IconComponent;
};
/** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */
export type Unstable_SlashCommandAction = {
readonly onExecute: (item: Unstable_TriggerItem) => void;
readonly removeOnExecute?: boolean | undefined;
};
/**
* Bundles slash command definitions (with inline `execute` callbacks) into
* `{adapter, action}` that plug directly into `ComposerTriggerPopover`.
* `execute` stays in the hook closure and is never attached to the returned
* `TriggerItem`, keeping items serializable.
*
* @deprecated Experimental since 2026-04-06. Not scheduled for removal; the API may change in any release.
*
* @example
* ```tsx
* const slash = unstable_useSlashCommandAdapter({
* commands: [
* { id: "summarize", execute: () => runSummarize(), icon: "FileText" },
* { id: "translate", execute: () => runTranslate(), icon: "Languages" },
* ],
* });
*
* <ComposerTriggerPopover char="/" {...slash} />
* ```
*/
export function unstable_useSlashCommandAdapter(
options: Unstable_UseSlashCommandAdapterOptions,
): {
adapter: Unstable_TriggerAdapter;
action: Unstable_SlashCommandAction;
iconMap?: Record<string, Unstable_IconComponent>;
fallbackIcon?: Unstable_IconComponent;
} {
const { commands, removeOnExecute } = options;
const commandsRef = useRef(commands);
const committedItemsRef = useRef<readonly Unstable_TriggerItem[]>(undefined);
const nextItems = commands.map(toItem);
// A referential cache, never state: `items` is always structurally equal to
// `nextItems`, so the render-time read cannot change what the adapter shows.
const items = areTriggerItemsEqual(committedItemsRef.current, nextItems)
? committedItemsRef.current
: nextItems;
// The adapter's callbacks must only observe commands from committed renders.
useIsomorphicLayoutEffect(() => {
commandsRef.current = commands;
committedItemsRef.current = items;
}, [commands, items]);
const adapter = useMemo<Unstable_TriggerAdapter>(
() => ({
categories: () => [],
categoryItems: () => [],
search: (query: string) => {
const lower = query.toLowerCase();
return items.filter((item) => matchesTriggerItemQuery(item, lower));
},
}),
[items],
);
const action = useMemo<Unstable_SlashCommandAction>(
() => ({
onExecute: (item) => {
commandsRef.current.find((c) => c.id === item.id)?.execute();
},
...(removeOnExecute !== undefined ? { removeOnExecute } : {}),
}),
[removeOnExecute],
);
return useMemo(
() => ({
adapter,
action,
...(options.iconMap ? { iconMap: options.iconMap } : {}),
...(options.fallbackIcon ? { fallbackIcon: options.fallbackIcon } : {}),
}),
[adapter, action, options.iconMap, options.fallbackIcon],
);
}
function toItem(cmd: Unstable_SlashCommand): Unstable_TriggerItem {
return {
id: cmd.id,
type: "command",
label: cmd.label ?? `/${cmd.id}`,
...(cmd.description !== undefined ? { description: cmd.description } : {}),
...(cmd.icon !== undefined ? { metadata: { icon: cmd.icon } } : {}),
};
}
function areTriggerItemsEqual(
previous: readonly Unstable_TriggerItem[] | undefined,
next: readonly Unstable_TriggerItem[],
): previous is readonly Unstable_TriggerItem[] {
if (!previous || previous.length !== next.length) return false;
return previous.every((item, index) => {
const nextItem = next[index];
return (
nextItem !== undefined &&
item.id === nextItem.id &&
item.label === nextItem.label &&
item.description === nextItem.description &&
item.metadata?.icon === nextItem.metadata?.icon
);
});
}