Skip to content
Source
Sign inSign up

ComposerCommandMenu.tsx

340 lines11.1 KB

1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
2import type { ComposerPathSearchTarget } from "@t3tools/client-runtime/state/threads";
3import {
4 resolveProviderSkillSourceKind,
5 type ProviderSkillSourceKind,
6} from "@t3tools/client-runtime/providerSkills";
7import {
8 type EnvironmentId,
9 type EnvironmentMachineKind,
10 type ProjectEntry,
11 type ProviderDriverKind,
12 type ProviderInstanceId,
13 type PullRequestContextMetadata,
14 type ScopedThreadRef,
15 type ServerProviderSkill,
16 type ServerProviderSlashCommand,
17} from "@t3tools/contracts";
18import {
19 BlocksIcon,
20 FolderIcon,
21 MessagesSquareIcon,
22 PackageIcon,
23 SettingsIcon,
24 UserRoundIcon,
25 type LucideIcon,
26} from "lucide-react";
27import { memo, useLayoutEffect, useRef, useState } from "react";
28​
29import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";
30import { cn } from "~/lib/utils";
31import { Badge } from "../ui/badge";
32import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";
33import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
34import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
35import { PierreEntryIcon } from "./PierreEntryIcon";
36import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
37import { ComposerBanner } from "./ComposerBanner";
38import { resolvePullRequestState } from "../pullRequest/pullRequestPresentation";
39​
40export type ComposerCommandItem =
41 | {
42 id: string;
43 type: "path";
44 path: string;
45 pathKind: ProjectEntry["kind"];
46 searchTarget: ComposerPathSearchTarget;
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 | {
88 id: string;
89 type: "environment";
90 environmentId: EnvironmentId;
91 machine: EnvironmentMachineKind;
92 label: string;
93 description: string;
94 }
95 | {
96 id: string;
97 type: "model";
98 instanceId: ProviderInstanceId;
99 model: string;
100 driverKind: ProviderDriverKind;
101 providerDisplayName: string;
102 acpRegistryAgentId: string | undefined;
103 acpRegistryIconUrl: string | undefined;
104 label: string;
105 description: string;
106 };
107​
108export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
109 listId: string;
110 items: ComposerCommandItem[];
111 resolvedTheme: "light" | "dark";
112 isLoading: boolean;
113 triggerKind: ComposerTriggerKind | null;
114 emptyStateText?: string;
115 activeItemId: string | null;
116 onHighlightedItemChange: (itemId: string | null) => void;
117 onSelect: (item: ComposerCommandItem) => void;
118}) {
119 const listRef = useRef<HTMLDivElement>(null);
120​
121 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the highlighted menu item's DOM node into view
122 useLayoutEffect(() => {
123 if (!props.activeItemId || !listRef.current) return;
124 const el = listRef.current.querySelector<HTMLElement>(
125 `[data-composer-item-id="${CSS.escape(props.activeItemId)}"]`,
126 );
127 el?.scrollIntoView({ block: "nearest" });
128 }, [props.activeItemId]);
129​
130 return (
131 <Command
132 autoHighlight={false}
133 mode="none"
134 onItemHighlighted={(highlightedValue) => {
135 props.onHighlightedItemChange(
136 typeof highlightedValue === "string" ? highlightedValue : null,
137 );
138 }}
139 >
140 <ComposerBanner.Surface
141 ref={listRef}
142 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"
143 data-composer-command-drawer="true"
144 >
145 {props.items.length > 0 ? (
146 <CommandList
147 id={props.listId}
148 aria-label={props.triggerKind ? LISTBOX_LABEL_BY_TRIGGER[props.triggerKind] : undefined}
149 className="max-h-72 min-h-0 scroll-pb-6"
150 >
151 <CommandGroup>
152 {props.items.map((item) => (
153 <ComposerCommandMenuItem
154 key={item.id}
155 optionId={composerSuggestionOptionId(props.listId, item.id)}
156 item={item}
157 triggerKind={props.triggerKind}
158 resolvedTheme={props.resolvedTheme}
159 isActive={props.activeItemId === item.id}
160 onHighlight={props.onHighlightedItemChange}
161 onSelect={props.onSelect}
162 />
163 ))}
164 </CommandGroup>
165 </CommandList>
166 ) : (
167 <div className="px-5 pt-3.5 pb-7">
168 <p className="text-secondary-label text-xs">
169 {props.isLoading
170 ? props.triggerKind === "skill" || props.triggerKind === "slash-command"
171 ? "Searching workspace skills..."
172 : props.triggerKind === "pull-request"
173 ? "Finding pull request..."
174 : "Searching workspace files..."
175 : (props.emptyStateText ??
176 (props.triggerKind === "skill"
177 ? "No skills found. Try / to browse provider commands."
178 : props.triggerKind === "path"
179 ? "No matching files or folders."
180 : "No matching command."))}
181 </p>
182 </div>
183 )}
184 </ComposerBanner.Surface>
185 </Command>
186 );
187});
188​
189const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
190 optionId: string;
191 item: ComposerCommandItem;
192 triggerKind: ComposerTriggerKind | null;
193 resolvedTheme: "light" | "dark";
194 isActive: boolean;
195 onHighlight: (itemId: string | null) => void;
196 onSelect: (item: ComposerCommandItem) => void;
197}) {
198 const skillSourceKind =
199 props.item.type === "skill" ? resolveProviderSkillSourceKind(props.item.skill) : null;
200 const isSlashSkill =
201 props.triggerKind === "slash-command" && props.item.type === "skill" ? props.item.skill : null;
202 const pullRequestPresentation =
203 props.item.type === "pull-request" ? resolvePullRequestState(props.item.pullRequest) : null;
204 const descriptionRef = useRef<HTMLSpanElement>(null);
205 const [descriptionTruncated, setDescriptionTruncated] = useState(false);
206 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the rendered description so only cut-off text gets a tooltip
207 useLayoutEffect(() => {
208 const description = descriptionRef.current;
209 setDescriptionTruncated(
210 props.isActive && !!description && description.scrollWidth > description.clientWidth,
211 );
212 }, [props.isActive]);
213​
214 return (
215 <CommandItem
216 render={<div id={props.optionId} />}
217 aria-selected={props.isActive}
218 value={props.item.id}
219 data-composer-item-id={props.item.id}
220 active={props.isActive}
221 onMouseMove={() => {
222 if (!props.isActive) props.onHighlight(props.item.id);
223 }}
224 onMouseDown={(event) => {
225 event.preventDefault();
226 }}
227 onClick={() => {
228 props.onSelect(props.item);
229 }}
230 >
231 {props.item.type === "path" ? (
232 <PierreEntryIcon
233 pathValue={props.item.path}
234 kind={props.item.pathKind}
235 theme={props.resolvedTheme}
236 />
237 ) : null}
238 {props.item.type === "thread" ? (
239 <MessagesSquareIcon aria-hidden="true" className="size-4 shrink-0 text-secondary-label" />
240 ) : null}
241 {props.item.type === "environment" ? (
242 <EnvironmentMachineIcon
243 kind={props.item.machine}
244 aria-hidden="true"
245 className="size-4 shrink-0 text-secondary-label"
246 />
247 ) : null}
248 {props.item.type === "model" ? (
249 <ProviderInstanceIcon
250 driverKind={props.item.driverKind}
251 displayName={props.item.providerDisplayName}
252 acpRegistryAgentId={props.item.acpRegistryAgentId}
253 acpRegistryIconUrl={props.item.acpRegistryIconUrl}
254 className="size-4 shrink-0"
255 iconClassName="size-4"
256 />
257 ) : null}
258 {pullRequestPresentation ? (
259 <pullRequestPresentation.Icon
260 role="img"
261 aria-label={pullRequestPresentation.label}
262 className={cn("size-4 shrink-0", pullRequestPresentation.toneClassName)}
263 />
264 ) : null}
265 <span className="flex min-w-0 flex-1 items-center gap-2">
266 <span className="min-w-0 max-w-[45%] shrink-0 truncate font-sans text-xs font-medium">
267 {isSlashSkill ? (
268 <>
269 <span className="text-secondary-label">/skill:</span>
270 {formatProviderSkillDisplayName(isSlashSkill)}
271 </>
272 ) : (
273 props.item.label
274 )}
275 </span>
276 <Tooltip open={descriptionTruncated}>
277 <TooltipTrigger
278 tabIndex={-1}
279 render={
280 <span
281 ref={descriptionRef}
282 className="min-w-0 flex-1 truncate text-left text-secondary-label text-xs"
283 />
284 }
285 >
286 {props.item.description}
287 </TooltipTrigger>
288 <TooltipPopup>{props.item.description}</TooltipPopup>
289 </Tooltip>
290 {skillSourceKind ? (
291 <SkillSourceBadge
292 kind={skillSourceKind}
293 showSkillSuffix={props.triggerKind === "skill"}
294 />
295 ) : null}
296 </span>
297 </CommandItem>
298 );
299});
300​
301export function composerSuggestionOptionId(listId: string, itemId: string): string {
302 // JSON escapes lone UTF-16 surrogates before URI encoding without losing identity.
303 return `${listId}-${encodeURIComponent(JSON.stringify(itemId))}`;
305​
306const LISTBOX_LABEL_BY_TRIGGER: Record<ComposerTriggerKind, string> = {
307 path: "Files and folders",
308 "pull-request": "Pull requests",
309 "slash-command": "Commands",
310 skill: "Skills",
311};
312​
313const SKILL_SOURCE_ICON_BY_KIND: Record<ProviderSkillSourceKind, LucideIcon> = {
314 app: BlocksIcon,
315 repo: FolderIcon,
316 project: FolderIcon,
317 personal: UserRoundIcon,
318 system: SettingsIcon,
319 other: PackageIcon,
320};
321​
322const SKILL_SOURCE_LABEL_BY_KIND: Record<ProviderSkillSourceKind, string> = {
323 app: "App",
324 repo: "Repo",
325 project: "Project",
326 personal: "Personal",
327 system: "System",
328 other: "Provider",
329};
330​
331function SkillSourceBadge(props: { kind: ProviderSkillSourceKind; showSkillSuffix: boolean }) {
332 const Icon = SKILL_SOURCE_ICON_BY_KIND[props.kind];
333 return (
334 <Badge className="ms-auto" variant="secondary">
335 <Icon aria-hidden="true" className="text-current" />
336 {SKILL_SOURCE_LABEL_BY_KIND[props.kind]}
337 {props.showSkillSuffix ? " Skill" : null}
338 </Badge>
339 );