1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
2import {
3 resolveProviderSkillSourceKind,
4 type ProviderSkillSourceKind,
5} from "@t3tools/client-runtime/providerSkills";
6import {
7 type ProjectEntry,
8 type ProviderDriverKind,
9 type PullRequestContextMetadata,
10 type ScopedThreadRef,
11 type ServerProviderSkill,
12 type ServerProviderSlashCommand,
13} from "@t3tools/contracts";
14import {
15 BlocksIcon,
16 FolderIcon,
17 MessagesSquareIcon,
18 PackageIcon,
19 SettingsIcon,
20 UserRoundIcon,
21 type LucideIcon,
22} from "lucide-react";
23import { memo, useLayoutEffect, useRef } from "react";
24
25import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";
26import { cn } from "~/lib/utils";
27import { Badge } from "../ui/badge";
28import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";
29import { PierreEntryIcon } from "./PierreEntryIcon";
30import { ComposerBanner } from "./ComposerBanner";
31import { resolvePullRequestState } from "../pullRequest/pullRequestPresentation";
32
33export type ComposerCommandItem =
34 | {
35 id: string;
36 type: "emoji";
37 emoji: string;
38 query: string;
39 label: string;
40 description: string;
41 }
42 | {
43 id: string;
44 type: "path";
45 path: string;
46 pathKind: ProjectEntry["kind"];
47 label: string;
48 description: string;
49 }
50 | {
51 id: string;
52 type: "slash-command";
53 command: ComposerSlashCommand;
54 label: string;
55 description: string;
56 }
57 | {
58 id: string;
59 type: "provider-slash-command";
60 provider: ProviderDriverKind;
61 command: ServerProviderSlashCommand;
62 label: string;
63 description: string;
64 }
65 | {
66 id: string;
67 type: "skill";
68 provider: ProviderDriverKind;
69 skill: ServerProviderSkill;
70 label: string;
71 description: string;
72 }
73 | {
74 id: string;
75 type: "pull-request";
76 pullRequest: PullRequestContextMetadata;
77 label: string;
78 description: string;
79 }
80 | {
81 id: string;
82 type: "thread";
83 thread: ScopedThreadRef;
84 label: string;
85 description: string;
86 };
87
88export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
89 listId: string;
90 items: ComposerCommandItem[];
91 resolvedTheme: "light" | "dark";
92 isLoading: boolean;
93 triggerKind: ComposerTriggerKind | null;
94 emptyStateText?: string;
95 activeItemId: string | null;
96 onHighlightedItemChange: (itemId: string | null) => void;
97 onSelect: (item: ComposerCommandItem) => void;
98}) {
99 const listRef = useRef<HTMLDivElement>(null);
100
101 useLayoutEffect(() => {
102 if (!props.activeItemId || !listRef.current) return;
103 const el = listRef.current.querySelector<HTMLElement>(
104 `[data-composer-item-id="${CSS.escape(props.activeItemId)}"]`,
105 );
106 el?.scrollIntoView({ block: "nearest" });
107 }, [props.activeItemId]);
108
109 return (
110 <Command
111 autoHighlight={false}
112 mode="none"
113 onItemHighlighted={(highlightedValue) => {
114 props.onHighlightedItemChange(
115 typeof highlightedValue === "string" ? highlightedValue : null,
116 );
117 }}
118 >
119 <ComposerBanner.Surface
120 ref={listRef}
121 className="flex min-h-0 w-full flex-col overflow-hidden pb-(--chat-composer-attachment-overlap) **:data-[slot=command-item]:rounded-lg **:data-[slot=scroll-area-scrollbar]:data-[orientation=vertical]:my-4"
122 data-composer-command-drawer="true"
123 >
124 {props.items.length > 0 ? (
125 <CommandList
126 id={props.listId}
127 aria-label={props.triggerKind ? LISTBOX_LABEL_BY_TRIGGER[props.triggerKind] : undefined}
128 className="max-h-72 min-h-0 scroll-pb-6"
129 >
130 <CommandGroup>
131 {props.items.map((item) => (
132 <ComposerCommandMenuItem
133 key={item.id}
134 optionId={composerSuggestionOptionId(props.listId, item.id)}
135 item={item}
136 triggerKind={props.triggerKind}
137 resolvedTheme={props.resolvedTheme}
138 isActive={props.activeItemId === item.id}
139 onHighlight={props.onHighlightedItemChange}
140 onSelect={props.onSelect}
141 />
142 ))}
143 </CommandGroup>
144 </CommandList>
145 ) : (
146 <div className="px-5 pt-3.5 pb-7">
147 <p className="text-secondary-label text-xs">
148 {props.isLoading
149 ? props.triggerKind === "emoji"
150 ? "Loading emoji…"
151 : props.triggerKind === "skill"
152 ? "Searching workspace skills..."
153 : props.triggerKind === "pull-request"
154 ? "Finding pull request..."
155 : "Searching workspace files..."
156 : (props.emptyStateText ??
157 (props.triggerKind === "skill"
158 ? "No skills found. Try / to browse provider commands."
159 : props.triggerKind === "path"
160 ? "No matching files or folders."
161 : "No matching command."))}
162 </p>
163 </div>
164 )}
165 </ComposerBanner.Surface>
166 </Command>
167 );
168});
169
170const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
171 optionId: string;
172 item: ComposerCommandItem;
173 triggerKind: ComposerTriggerKind | null;
174 resolvedTheme: "light" | "dark";
175 isActive: boolean;
176 onHighlight: (itemId: string | null) => void;
177 onSelect: (item: ComposerCommandItem) => void;
178}) {
179 const skillSourceKind =
180 props.item.type === "skill" ? resolveProviderSkillSourceKind(props.item.skill) : null;
181 const isSlashSkill =
182 props.triggerKind === "slash-command" && props.item.type === "skill" ? props.item.skill : null;
183 const pullRequestPresentation =
184 props.item.type === "pull-request" ? resolvePullRequestState(props.item.pullRequest) : null;
185
186 return (
187 <CommandItem
188 render={<div id={props.optionId} />}
189 aria-selected={props.isActive}
190 value={props.item.id}
191 data-composer-item-id={props.item.id}
192 active={props.isActive}
193 onMouseMove={() => {
194 if (!props.isActive) props.onHighlight(props.item.id);
195 }}
196 onMouseDown={(event) => {
197 event.preventDefault();
198 }}
199 onClick={() => {
200 props.onSelect(props.item);
201 }}
202 >
203 {props.item.type === "emoji" ? (
204 <span aria-hidden="true" className="w-5 shrink-0 text-center text-lg leading-none">
205 {props.item.emoji}
206 </span>
207 ) : null}
208 {props.item.type === "path" ? (
209 <PierreEntryIcon
210 pathValue={props.item.path}
211 kind={props.item.pathKind}
212 theme={props.resolvedTheme}
213 />
214 ) : null}
215 {props.item.type === "thread" ? (
216 <MessagesSquareIcon aria-hidden="true" className="size-4 shrink-0 text-secondary-label" />
217 ) : null}
218 {pullRequestPresentation ? (
219 <pullRequestPresentation.Icon
220 role="img"
221 aria-label={pullRequestPresentation.label}
222 className={cn("size-4 shrink-0", pullRequestPresentation.toneClassName)}
223 />
224 ) : null}
225 <span className="flex min-w-0 flex-1 items-center gap-2">
226 <span className="min-w-0 max-w-[45%] shrink-0 truncate font-sans text-xs font-medium">
227 {isSlashSkill ? (
228 <>
229 <span className="text-secondary-label">/skill:</span>
230 {formatProviderSkillDisplayName(isSlashSkill)}
231 </>
232 ) : (
233 props.item.label
234 )}
235 </span>
236 <span className="min-w-0 flex-1 truncate text-left text-secondary-label text-xs">
237 {props.item.description}
238 </span>
239 {skillSourceKind ? (
240 <SkillSourceBadge
241 kind={skillSourceKind}
242 showSkillSuffix={props.triggerKind === "skill"}
243 />
244 ) : null}
245 </span>
246 </CommandItem>
247 );
248});
249
250export function composerSuggestionOptionId(listId: string, itemId: string): string {
251 // JSON escapes lone UTF-16 surrogates before URI encoding without losing identity.
252 return `${listId}-${encodeURIComponent(JSON.stringify(itemId))}`;
253}
254
255const LISTBOX_LABEL_BY_TRIGGER: Record<ComposerTriggerKind, string> = {
256 path: "Files and folders",
257 "pull-request": "Pull requests",
258 "slash-command": "Commands",
259 skill: "Skills",
260 emoji: "Emoji",
261};
262
263const SKILL_SOURCE_ICON_BY_KIND: Record<ProviderSkillSourceKind, LucideIcon> = {
264 app: BlocksIcon,
265 repo: FolderIcon,
266 project: FolderIcon,
267 personal: UserRoundIcon,
268 system: SettingsIcon,
269 other: PackageIcon,
270};
271
272const SKILL_SOURCE_LABEL_BY_KIND: Record<ProviderSkillSourceKind, string> = {
273 app: "App",
274 repo: "Repo",
275 project: "Project",
276 personal: "Personal",
277 system: "System",
278 other: "Provider",
279};
280
281function SkillSourceBadge(props: { kind: ProviderSkillSourceKind; showSkillSuffix: boolean }) {
282 const Icon = SKILL_SOURCE_ICON_BY_KIND[props.kind];
283 return (
284 <Badge className="ms-auto" variant="secondary">
285 <Icon aria-hidden="true" className="text-current" />
286 {SKILL_SOURCE_LABEL_BY_KIND[props.kind]}
287 {props.showSkillSuffix ? " Skill" : null}
288 </Badge>
289 );
290}