224 lines · 7.0 KB · TypeScript
RawDownload
import {  formatProviderSkillDisplayName,  resolveProviderSkillSourceKind,  type ProviderSkillSourceKind,} from "@t3tools/client-runtime/providerSkills";import {  type ProjectEntry,  type ProviderDriverKind,  type ServerProviderSkill,  type ServerProviderSlashCommand,} from "@t3tools/contracts";import {  BlocksIcon,  FolderIcon,  PackageIcon,  SettingsIcon,  UserRoundIcon,  type LucideIcon,} from "lucide-react";import { memo, useLayoutEffect, useRef } from "react"; import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";import { cn } from "~/lib/utils";import { Badge } from "../ui/badge";import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";import { PierreEntryIcon } from "./PierreEntryIcon";import { ComposerBanner } from "./ComposerBanner"; export type ComposerCommandItem =  | {      id: string;      type: "path";      path: string;      pathKind: ProjectEntry["kind"];      label: string;      description: string;    }  | {      id: string;      type: "slash-command";      command: ComposerSlashCommand;      label: string;      description: string;    }  | {      id: string;      type: "provider-slash-command";      provider: ProviderDriverKind;      command: ServerProviderSlashCommand;      label: string;      description: string;    }  | {      id: string;      type: "skill";      provider: ProviderDriverKind;      skill: ServerProviderSkill;      label: string;      description: string;    }; export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {  items: ComposerCommandItem[];  resolvedTheme: "light" | "dark";  isLoading: boolean;  triggerKind: ComposerTriggerKind | null;  emptyStateText?: string;  activeItemId: string | null;  onHighlightedItemChange: (itemId: string | null) => void;  onSelect: (item: ComposerCommandItem) => void;}) {  const listRef = useRef<HTMLDivElement>(null);   useLayoutEffect(() => {    if (!props.activeItemId || !listRef.current) return;    const el = listRef.current.querySelector<HTMLElement>(      `[data-composer-item-id="${CSS.escape(props.activeItemId)}"]`,    );    el?.scrollIntoView({ block: "nearest" });  }, [props.activeItemId]);   return (    <Command      autoHighlight={false}      mode="none"      onItemHighlighted={(highlightedValue) => {        props.onHighlightedItemChange(          typeof highlightedValue === "string" ? highlightedValue : null,        );      }}    >      <ComposerBanner.Surface        ref={listRef}        className="flex min-h-0 w-full flex-col overflow-hidden pb-(--chat-composer-attachment-overlap) **:data-[slot=scroll-area-scrollbar]:data-[orientation=vertical]:my-4"        data-composer-command-drawer="true"      >        {props.items.length > 0 ? (          <CommandList className="max-h-72 min-h-0 scroll-pb-6">            <CommandGroup>              {props.items.map((item) => (                <ComposerCommandMenuItem                  key={item.id}                  item={item}                  triggerKind={props.triggerKind}                  resolvedTheme={props.resolvedTheme}                  isActive={props.activeItemId === item.id}                  onHighlight={props.onHighlightedItemChange}                  onSelect={props.onSelect}                />              ))}            </CommandGroup>          </CommandList>        ) : (          <div className="px-5 pt-3.5 pb-7">            <p className="text-secondary-label text-xs">              {props.isLoading                ? props.triggerKind === "skill"                  ? "Searching workspace skills..."                  : "Searching workspace files..."                : (props.emptyStateText ??                  (props.triggerKind === "skill"                    ? "No skills found. Try / to browse provider commands."                    : props.triggerKind === "path"                      ? "No matching files or folders."                      : "No matching command."))}            </p>          </div>        )}      </ComposerBanner.Surface>    </Command>  );}); const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {  item: ComposerCommandItem;  triggerKind: ComposerTriggerKind | null;  resolvedTheme: "light" | "dark";  isActive: boolean;  onHighlight: (itemId: string | null) => void;  onSelect: (item: ComposerCommandItem) => void;}) {  const skillSourceKind =    props.item.type === "skill" ? resolveProviderSkillSourceKind(props.item.skill) : null;  const isSlashSkill =    props.triggerKind === "slash-command" && props.item.type === "skill" ? props.item.skill : null;   return (    <CommandItem      value={props.item.id}      data-composer-item-id={props.item.id}      className={cn(        "cursor-pointer select-none gap-3 rounded-lg px-3 py-2! hover:bg-transparent hover:text-inherit data-highlighted:bg-transparent data-highlighted:text-inherit",        props.isActive && "bg-accent! text-accent-foreground!",      )}      onMouseMove={() => {        if (!props.isActive) props.onHighlight(props.item.id);      }}      onMouseDown={(event) => {        event.preventDefault();      }}      onClick={() => {        props.onSelect(props.item);      }}    >      {props.item.type === "path" ? (        <PierreEntryIcon          pathValue={props.item.path}          kind={props.item.pathKind}          theme={props.resolvedTheme}        />      ) : null}      <span className="flex min-w-0 flex-1 items-center gap-2">        <span className="min-w-0 max-w-[45%] shrink-0 truncate font-sans text-xs font-medium">          {isSlashSkill ? (            <>              <span className="text-secondary-label">/skill:</span>              {formatProviderSkillDisplayName(isSlashSkill)}            </>          ) : (            props.item.label          )}        </span>        <span className="min-w-0 max-w-[48ch] flex-1 truncate text-left text-secondary-label text-xs">          {props.item.description}        </span>        {skillSourceKind ? (          <SkillSourceBadge            kind={skillSourceKind}            showSkillSuffix={props.triggerKind === "skill"}          />        ) : null}      </span>    </CommandItem>  );}); const SKILL_SOURCE_ICON_BY_KIND: Record<ProviderSkillSourceKind, LucideIcon> = {  app: BlocksIcon,  repo: FolderIcon,  project: FolderIcon,  personal: UserRoundIcon,  system: SettingsIcon,  other: PackageIcon,}; const SKILL_SOURCE_LABEL_BY_KIND: Record<ProviderSkillSourceKind, string> = {  app: "App",  repo: "Repo",  project: "Project",  personal: "Personal",  system: "System",  other: "Provider",}; function SkillSourceBadge(props: { kind: ProviderSkillSourceKind; showSkillSuffix: boolean }) {  const Icon = SKILL_SOURCE_ICON_BY_KIND[props.kind];  return (    <Badge className="ms-auto" variant="secondary">      <Icon aria-hidden="true" className="text-current" />      {SKILL_SOURCE_LABEL_BY_KIND[props.kind]}      {props.showSkillSuffix ? " Skill" : null}    </Badge>  );}