From b63fe3f800035b9724d2c1a7905f25f11f31c724 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 10 Apr 2026 16:38:03 +0800 Subject: [PATCH] feat(chat): add experts menu to message input toolbar Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/chat/experts-command-menu.tsx | 69 ++++ src/components/chat/message-input.tsx | 314 +++++++++++++++++-- src/hooks/use-built-in-experts.ts | 84 +++++ src/i18n/messages/ar.json | 4 +- src/i18n/messages/de.json | 4 +- src/i18n/messages/en.json | 4 +- src/i18n/messages/es.json | 4 +- src/i18n/messages/fr.json | 4 +- src/i18n/messages/ja.json | 4 +- src/i18n/messages/ko.json | 4 +- src/i18n/messages/pt.json | 4 +- src/i18n/messages/zh-CN.json | 4 +- src/i18n/messages/zh-TW.json | 4 +- 13 files changed, 479 insertions(+), 28 deletions(-) create mode 100644 src/components/chat/experts-command-menu.tsx create mode 100644 src/hooks/use-built-in-experts.ts diff --git a/src/components/chat/experts-command-menu.tsx b/src/components/chat/experts-command-menu.tsx new file mode 100644 index 0000000..5f6ba3e --- /dev/null +++ b/src/components/chat/experts-command-menu.tsx @@ -0,0 +1,69 @@ +"use client" + +import { + Bot, + Bug, + CheckCheck, + FileCode2, + FlaskConical, + GitBranch, + GitFork, + GitMerge, + Lightbulb, + ListTodo, + MessageSquareQuote, + MessageSquareReply, + PlayCircle, + Sparkles, + type LucideIcon, +} from "lucide-react" + +const ICON_MAP: Record = { + Lightbulb, + ListTodo, + PlayCircle, + Bot, + GitFork, + GitBranch, + FlaskConical, + CheckCheck, + Bug, + MessageSquareQuote, + MessageSquareReply, + GitMerge, + Sparkles, + FileCode2, +} + +/** + * Resolve the lucide-react component referenced by an expert's `icon` + * metadata field. Falls back to `Sparkles` when the name is missing or + * does not match a known icon. + */ +export function getExpertIcon(name: string | null | undefined): LucideIcon { + return (name && ICON_MAP[name]) || Sparkles +} + +/** + * Resolve a localized string from an expert metadata dictionary. + * + * next-intl locales look like `zh_cn`, while the bundled expert metadata + * uses BCP-47 style keys such as `zh-CN`. Normalize both sides, then fall + * back to `en`, then to any available entry when the exact locale is + * missing. + */ +export function pickExpertLocalized( + dict: Record | undefined, + locale: string +): string { + if (!dict) return "" + if (dict[locale]) return dict[locale] + const normalized = locale.replace("_", "-") + if (dict[normalized]) return dict[normalized] + const [lang] = normalized.split("-") + const match = Object.keys(dict).find( + (key) => key.toLowerCase().split("-")[0] === lang.toLowerCase() + ) + if (match) return dict[match] + return dict.en ?? Object.values(dict)[0] ?? "" +} diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index c83e44a..0b5903d 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { isDesktop } from "@/lib/platform" import Image from "next/image" -import { useTranslations } from "next-intl" +import { useLocale, useTranslations } from "next-intl" import { Button } from "@/components/ui/button" import { Popover, @@ -21,6 +21,7 @@ import { Plus, Send, Command, + Sparkles, Square, X, } from "lucide-react" @@ -28,6 +29,8 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { ImagePreviewDialog } from "@/components/ui/image-preview-dialog" @@ -39,6 +42,7 @@ import { openFileDialog } from "@/lib/platform" import { disposeTauriListener } from "@/lib/tauri-listener" import type { AvailableCommandInfo, + ExpertListItem, PromptCapabilitiesInfo, PromptDraft, PromptInputBlock, @@ -53,10 +57,14 @@ import { } from "@/lib/session-attachment-events" import { ModeSelector } from "@/components/chat/mode-selector" import { SessionConfigSelector } from "@/components/chat/session-config-selector" -import { SlashCommandMenu } from "@/components/chat/slash-command-menu" +import { + getExpertIcon, + pickExpertLocalized, +} from "@/components/chat/experts-command-menu" import { FileMentionMenu } from "@/components/chat/file-mention-menu" import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content" import { useFileTree } from "@/hooks/use-file-tree" +import { useBuiltInExperts } from "@/hooks/use-built-in-experts" import { joinFsPath } from "@/lib/path-utils" import { clearMessageInputDraft, @@ -300,6 +308,81 @@ export function MessageInput({ }: MessageInputProps) { const t = useTranslations("Folder.chat.messageInput") const tQueue = useTranslations("Folder.chat.messageQueue") + const tExperts = useTranslations("ExpertsSettings") + const locale = useLocale() + const builtInExperts = useBuiltInExperts() + const expertIdSet = useMemo( + () => new Set(builtInExperts.map((item) => item.metadata.id)), + [builtInExperts] + ) + // Derive the list of experts this specific agent session actually knows + // about. The backend advertises every enabled expert via its skill + // directory, so any expert whose id appears in `availableCommands` is + // guaranteed to be linked for the current agent. Using this intersection + // keeps the experts button in lockstep with what the agent will accept + // — an expert disabled in settings simply never reaches this dropdown. + const availableExperts = useMemo(() => { + if (!availableCommands || availableCommands.length === 0) return [] + const agentCommandNames = new Set(availableCommands.map((cmd) => cmd.name)) + return builtInExperts.filter((item) => + agentCommandNames.has(item.metadata.id) + ) + }, [availableCommands, builtInExperts]) + // Stable presentation order for expert categories in the button + // dropdown. Keep this in sync with experts-settings.tsx so both surfaces + // group experts the same way. + const groupedExperts = useMemo(() => { + const CATEGORY_SORT: Record = { + discovery: 1, + planning: 2, + execution: 3, + quality: 4, + debugging: 5, + review: 6, + meta: 7, + } + const groups = new Map() + const sorted = [...availableExperts].sort((a, b) => { + const ca = CATEGORY_SORT[a.metadata.category] ?? 99 + const cb = CATEGORY_SORT[b.metadata.category] ?? 99 + if (ca !== cb) return ca - cb + const sa = a.metadata.sort_order ?? 0 + const sb = b.metadata.sort_order ?? 0 + if (sa !== sb) return sa - sb + return a.metadata.id.localeCompare(b.metadata.id) + }) + for (const item of sorted) { + const list = groups.get(item.metadata.category) ?? [] + list.push(item) + groups.set(item.metadata.category, list) + } + return Array.from(groups.entries()).sort( + (a, b) => (CATEGORY_SORT[a[0]] ?? 99) - (CATEGORY_SORT[b[0]] ?? 99) + ) + }, [availableExperts]) + const translateExpertCategory = useCallback( + (category: string): string => { + switch (category) { + case "discovery": + return tExperts("categories.discovery") + case "planning": + return tExperts("categories.planning") + case "execution": + return tExperts("categories.execution") + case "quality": + return tExperts("categories.quality") + case "debugging": + return tExperts("categories.debugging") + case "review": + return tExperts("categories.review") + case "meta": + return tExperts("categories.meta") + default: + return category + } + }, + [tExperts] + ) const { shortcuts } = useShortcutSettings() const effectiveDraftStorageKey = draftStorageKey ?? attachmentTabId ?? null const resolvedPlaceholder = placeholder ?? t("askAnything") @@ -423,11 +506,18 @@ export function MessageInput({ const hasSendableContent = text.trim().length > 0 || hasAttachments // ── Slash command autocomplete ── + // + // Built-in experts are always surfaced via a dedicated button, so any + // agent-advertised command whose name matches an expert id is hidden + // from the slash list to avoid showing the same item twice. Autocomplete + // for `/` merges the filtered agent commands and the built-in experts + // into a single flat list — agent commands first, then experts — so + // typing `/brain` still completes `brainstorming`. const [slashMenuOpen, setSlashMenuOpen] = useState(false) const [slashSelectedIndex, setSlashSelectedIndex] = useState(0) const slashCommands = useMemo( - () => availableCommands ?? [], - [availableCommands] + () => (availableCommands ?? []).filter((cmd) => !expertIdSet.has(cmd.name)), + [availableCommands, expertIdSet] ) const filteredSlashCommands = useMemo(() => { if (!slashMenuOpen || slashCommands.length === 0) return [] @@ -438,6 +528,17 @@ export function MessageInput({ cmd.name.toLowerCase().startsWith(filter) ) }, [slashMenuOpen, slashCommands, text]) + const filteredSlashExperts = useMemo(() => { + if (!slashMenuOpen || availableExperts.length === 0) return [] + const match = text.match(/^\/(\S*)$/) + if (!match) return [] + const filter = match[1].toLowerCase() + return availableExperts.filter((item) => + item.metadata.id.toLowerCase().startsWith(filter) + ) + }, [slashMenuOpen, availableExperts, text]) + const slashAutocompleteCount = + filteredSlashCommands.length + filteredSlashExperts.length // ── @ file mention autocomplete ── const [atMenuOpen, setAtMenuOpen] = useState(false) @@ -799,6 +900,35 @@ export function MessageInput({ }) }, []) + const handleExpertAutocompleteSelect = useCallback( + (expert: ExpertListItem) => { + setText(`/${expert.metadata.id} `) + setSlashMenuOpen(false) + }, + [] + ) + + // Experts always inject `/expert-id ` at the very front of the input, + // never at the cursor. The expert skill is a whole-turn directive that + // the agent inspects first, so prepending keeps semantics unambiguous + // regardless of what the user has already typed. + const handleExpertPopoverSelect = useCallback((expert: ExpertListItem) => { + const current = textRef.current + const insertion = `/${expert.metadata.id} ` + const newText = current.length === 0 ? insertion : insertion + current + setText(newText) + requestAnimationFrame(() => { + const ta = textareaRef.current + if (ta) { + ta.focus() + // Place the caret just after the inserted prefix so the user can + // start (or continue) typing context for the expert. + const pos = insertion.length + ta.setSelectionRange(pos, pos) + } + }) + }, []) + const atTriggerPosRef = useRef(atTriggerPos) useEffect(() => { atTriggerPosRef.current = atTriggerPos @@ -834,8 +964,12 @@ export function MessageInput({ const value = e.target.value setText(value) - // Slash command detection (only at start of input) - if (slashCommands.length > 0 && /^\/(\S*)$/.test(value)) { + // Slash command detection (only at start of input). Either an agent + // command or an agent-enabled expert can satisfy the prompt, so open + // the menu whenever at least one of them is available. + const hasSlashSource = + slashCommands.length > 0 || availableExperts.length > 0 + if (hasSlashSource && /^\/(\S*)$/.test(value)) { setSlashSelectedIndex(0) setSlashMenuOpen(true) setAtMenuOpen(false) @@ -861,7 +995,7 @@ export function MessageInput({ } setAtMenuOpen(false) }, - [slashCommands.length, defaultPath] + [slashCommands.length, availableExperts.length, defaultPath] ) const handlePickFiles = useCallback(async () => { @@ -1143,24 +1277,33 @@ export function MessageInput({ return } - if (slashMenuOpen && filteredSlashCommands.length > 0) { + if (slashMenuOpen && slashAutocompleteCount > 0) { if (e.key === "ArrowDown") { e.preventDefault() setSlashSelectedIndex((i) => - i < filteredSlashCommands.length - 1 ? i + 1 : 0 + i < slashAutocompleteCount - 1 ? i + 1 : 0 ) return } if (e.key === "ArrowUp") { e.preventDefault() setSlashSelectedIndex((i) => - i > 0 ? i - 1 : filteredSlashCommands.length - 1 + i > 0 ? i - 1 : slashAutocompleteCount - 1 ) return } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault() - handleSlashSelect(filteredSlashCommands[slashSelectedIndex]) + if (slashSelectedIndex < filteredSlashCommands.length) { + handleSlashSelect(filteredSlashCommands[slashSelectedIndex]) + } else { + const expertIndex = + slashSelectedIndex - filteredSlashCommands.length + const expert = filteredSlashExperts[expertIndex] + if (expert) { + handleExpertAutocompleteSelect(expert) + } + } return } if (e.key === "Escape") { @@ -1227,9 +1370,12 @@ export function MessageInput({ handleSend, shortcuts, slashMenuOpen, + slashAutocompleteCount, filteredSlashCommands, + filteredSlashExperts, slashSelectedIndex, handleSlashSelect, + handleExpertAutocompleteSelect, atMenuOpen, filteredAtFiles, atSelectedIndex, @@ -1400,12 +1546,74 @@ export function MessageInput({ onDragLeave={handleContainerDragLeave} onDrop={handleContainerDrop} > - {slashMenuOpen && filteredSlashCommands.length > 0 && ( - + {slashMenuOpen && slashAutocompleteCount > 0 && ( +
+ {filteredSlashCommands.map((cmd, i) => ( + + ))} + {filteredSlashExperts.map((expert, i) => { + const absoluteIndex = filteredSlashCommands.length + i + const Icon = getExpertIcon(expert.metadata.icon) + const name = + pickExpertLocalized(expert.metadata.display_name, locale) || + expert.metadata.id + const description = pickExpertLocalized( + expert.metadata.description, + locale + ) + return ( + + ) + })} +
)} {atMenuOpen && filteredAtFiles.length > 0 && ( + + + + + + {availableExperts.length === 0 ? ( +
+ {t("expertsEmptyForAgent")} +
+ ) : ( + groupedExperts.map(([category, items], groupIndex) => ( +
+ {groupIndex > 0 && } + + {translateExpertCategory(category)} + + {items.map((expert) => { + const Icon = getExpertIcon(expert.metadata.icon) + const name = + pickExpertLocalized( + expert.metadata.display_name, + locale + ) || expert.metadata.id + const description = pickExpertLocalized( + expert.metadata.description, + locale + ) + return ( + handleExpertPopoverSelect(expert)} + className="items-start gap-2" + > + +
+
{name}
+ {description && ( +
+ {description} +
+ )} +
+
+ ) + })} +
+ )) + )} +
+