Files
homarr/apps/nextjs/src/components/assistant/assistant-composer-triggers.tsx
T
+12 fcd7c979df feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
2026-10-02 21:00:14 +02:00

199 lines
7.1 KiB
TypeScript

"use client";
import type { RefObject } from "react";
import { useContext, useLayoutEffect, useMemo, useRef } from "react";
import type { Unstable_TriggerItem } from "@assistant-ui/react";
import {
ComposerPrimitive,
useAui,
unstable_useMentionAdapter,
unstable_useSlashCommandAdapter,
unstable_useTriggerPopoverScopeContext,
} from "@assistant-ui/react";
import { Text, ThemeIcon } from "@mantine/core";
import { IconAt, IconChevronRight } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { AssistantDirectiveEntitiesContext, contextIcons } from "./assistant-message-content";
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
const TriggerPopoverAutoScroll = ({ viewportRef }: { viewportRef: RefObject<HTMLDivElement | null> }) => {
const { activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query } =
unstable_useTriggerPopoverScopeContext();
useLayoutEffect(() => {
if (!open) return;
const viewport = viewportRef.current;
const highlightedItem = viewport?.querySelector<HTMLElement>("[data-highlighted]");
if (!viewport || !highlightedItem) return;
const viewportRect = viewport.getBoundingClientRect();
const itemRect = highlightedItem.getBoundingClientRect();
const viewportTop = viewportRect.top + viewport.clientTop;
const viewportBottom = viewportTop + viewport.clientHeight;
viewport.scrollTop = getNearestTriggerScrollTop({
scrollTop: viewport.scrollTop,
viewportTop,
viewportBottom,
itemTop: itemRect.top,
itemBottom: itemRect.bottom,
});
}, [activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query, viewportRef]);
return null;
};
const TriggerItem = ({ item, index }: { item: Unstable_TriggerItem; index: number }) => {
const Icon =
typeof item.metadata?.icon === "string" && item.metadata.icon in contextIcons
? contextIcons[item.metadata.icon as keyof typeof contextIcons]
: IconAt;
return (
<ComposerPrimitive.Unstable_TriggerPopoverItem className={classes.triggerItem} item={item} index={index}>
<ThemeIcon size="sm" variant="light" color="gray">
<Icon size={13} />
</ThemeIcon>
<div className={classes.triggerItemText}>
<Text size="sm" fw={600} lineClamp={1}>
{item.label}
</Text>
{item.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{item.description}
</Text>
)}
</div>
</ComposerPrimitive.Unstable_TriggerPopoverItem>
);
};
export const ComposerTriggers = () => {
const t = useI18n("assistant");
const aui = useAui();
const mentionViewportRef = useRef<HTMLDivElement>(null);
const slashViewportRef = useRef<HTMLDivElement>(null);
const { entities, isLoading } = useContext(AssistantDirectiveEntitiesContext);
const categories = useMemo(
() =>
(["app", "integration", "board", "widget"] as const).map((type) => ({
id: type,
label: t(`mentions.${type}`),
items: entities
.filter((entity) => entity.type === type)
.map((entity) => ({
id: entity.id,
type: entity.type,
label: entity.label,
description: entity.description,
icon: entity.type,
})),
})),
[entities, t],
);
const mention = unstable_useMentionAdapter({
categories,
includeModelContextTools: {
category: { id: "tools", label: t("mentions.tools") },
formatLabel: (name) => name.replaceAll("_", " "),
icon: "tools",
},
iconMap: contextIcons,
fallbackIcon: IconAt,
});
const slash = unstable_useSlashCommandAdapter({
removeOnExecute: true,
commands: [
{
id: "health",
label: "/health",
description: t("commands.health"),
execute: () => aui.composer().setText(t("suggestions.health.prompt")),
},
{
id: "explore",
label: "/explore",
description: t("commands.explore"),
execute: () => aui.composer().setText(t("suggestions.explore.prompt")),
},
{
id: "media",
label: "/media",
description: t("commands.media"),
execute: () => aui.composer().setText(t("suggestions.media.prompt")),
},
{
id: "style",
label: "/style",
description: t("commands.style"),
execute: () => aui.composer().setText(t("suggestions.style.prompt")),
},
{
id: "design-widget",
label: "/design-widget",
description: t("commands.designWidget"),
execute: () => aui.composer().setText(t("suggestions.designWidget.prompt")),
},
],
});
return (
<>
<ComposerPrimitive.Unstable_TriggerPopover
ref={mentionViewportRef}
className={classes.triggerPopover}
char="@"
adapter={mention.adapter}
isLoading={isLoading}
aria-label={t("mentions.menu")}
>
<TriggerPopoverAutoScroll viewportRef={mentionViewportRef} />
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...mention.directive} />
<ComposerPrimitive.Unstable_TriggerPopoverCategories className={classes.triggerList}>
{(items) =>
items.map((category) => {
const Icon = contextIcons[category.id as keyof typeof contextIcons] ?? IconAt;
return (
<ComposerPrimitive.Unstable_TriggerPopoverCategoryItem
key={category.id}
categoryId={category.id}
className={classes.triggerItem}
>
<ThemeIcon size="sm" variant="light" color="gray">
<Icon size={13} />
</ThemeIcon>
<Text size="sm" fw={600} flex={1}>
{category.label}
</Text>
<IconChevronRight size={14} />
</ComposerPrimitive.Unstable_TriggerPopoverCategoryItem>
);
})
}
</ComposerPrimitive.Unstable_TriggerPopoverCategories>
<ComposerPrimitive.Unstable_TriggerPopoverItems className={classes.triggerList}>
{(items) =>
items.map((item, index) => <TriggerItem key={`${item.type}:${item.id}`} item={item} index={index} />)
}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>
<ComposerPrimitive.Unstable_TriggerPopover
ref={slashViewportRef}
className={classes.triggerPopover}
char="/"
adapter={slash.adapter}
aria-label={t("commands.menu")}
>
<TriggerPopoverAutoScroll viewportRef={slashViewportRef} />
<ComposerPrimitive.Unstable_TriggerPopover.Action {...slash.action} />
<ComposerPrimitive.Unstable_TriggerPopoverItems className={classes.triggerList}>
{(items) => items.map((item, index) => <TriggerItem key={item.id} item={item} index={index} />)}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>
</>
);
};