| 1 | import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; |
| 2 | import { |
| 3 | resolveProviderSkillSourceKind, |
| 4 | type ProviderSkillSourceKind, |
| 5 | } from "@t3tools/client-runtime/providerSkills"; |
| 6 | import { |
| 7 | type ProjectEntry, |
| 8 | type ProviderDriverKind, |
| 9 | type PullRequestContextMetadata, |
| 10 | type ScopedThreadRef, |
| 11 | type ServerProviderSkill, |
| 12 | type ServerProviderSlashCommand, |
| 13 | } from "@t3tools/contracts"; |
| 14 | import { |
| 15 | BlocksIcon, |
| 16 | FolderIcon, |
| 17 | MessagesSquareIcon, |
| 18 | PackageIcon, |
| 19 | SettingsIcon, |
| 20 | UserRoundIcon, |
| 21 | type LucideIcon, |
| 22 | } from "lucide-react"; |
| 23 | import { memo, useLayoutEffect, useRef } from "react"; |
| 24 | |
| 25 | import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; |
| 26 | import { cn } from "~/lib/utils"; |
| 27 | import { Badge } from "../ui/badge"; |
| 28 | import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command"; |
| 29 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| 30 | import { ComposerBanner } from "./ComposerBanner"; |
| 31 | import { resolvePullRequestState } from "../pullRequest/pullRequestPresentation"; |
| 32 | |
| 33 | export 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 | |
| 88 | export 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 | |
| 170 | const 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 | |
| 250 | export 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 | |
| 255 | const 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 | |
| 263 | const 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 | |
| 272 | const 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 | |
| 281 | function 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 | } |