feat(chat): add experts menu to message input toolbar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-10 16:38:03 +08:00
parent b876022377
commit b63fe3f800
13 changed files with 479 additions and 28 deletions

View File

@@ -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<string, LucideIcon> = {
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<string, string> | 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] ?? ""
}

View File

@@ -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<string, number> = {
discovery: 1,
planning: 2,
execution: 3,
quality: 4,
debugging: 5,
review: 6,
meta: 7,
}
const groups = new Map<string, typeof availableExperts>()
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 && (
<SlashCommandMenu
commands={filteredSlashCommands}
selectedIndex={slashSelectedIndex}
onSelect={handleSlashSelect}
/>
{slashMenuOpen && slashAutocompleteCount > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-1 z-50 max-h-64 overflow-y-auto rounded-xl border border-border bg-popover p-1 shadow-lg">
{filteredSlashCommands.map((cmd, i) => (
<button
key={`cmd-${cmd.name}`}
type="button"
className={cn(
"flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left text-sm",
i === slashSelectedIndex
? "bg-accent text-accent-foreground"
: "hover:bg-muted"
)}
onMouseDown={(e) => {
e.preventDefault()
handleSlashSelect(cmd)
}}
>
<span className="shrink-0 font-mono text-primary">
/{cmd.name}
</span>
<span className="truncate text-xs text-muted-foreground">
{cmd.description}
</span>
</button>
))}
{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 (
<button
key={`expert-${expert.metadata.id}`}
type="button"
className={cn(
"flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left text-sm",
absoluteIndex === slashSelectedIndex
? "bg-accent text-accent-foreground"
: "hover:bg-muted"
)}
onMouseDown={(e) => {
e.preventDefault()
handleExpertAutocompleteSelect(expert)
}}
>
<Icon className="mt-0.5 size-4 shrink-0 text-primary/80" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">{name}</span>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
/{expert.metadata.id}
</span>
</div>
{description && (
<div className="truncate text-xs text-muted-foreground">
{description}
</div>
)}
</div>
</button>
)
})}
</div>
)}
{atMenuOpen && filteredAtFiles.length > 0 && (
<FileMentionMenu
@@ -1508,6 +1716,72 @@ export function MessageInput({
>
<Plus className="size-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={disabled}
variant="outline"
size="icon"
className="h-6 w-6 shrink-0 bg-transparent"
title={t("expertSkills")}
>
<Sparkles className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
className="min-w-80 overflow-y-auto"
style={{
maxHeight:
"min(32rem, var(--radix-dropdown-menu-content-available-height))",
}}
>
{availableExperts.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
{t("expertsEmptyForAgent")}
</div>
) : (
groupedExperts.map(([category, items], groupIndex) => (
<div key={category}>
{groupIndex > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="text-[11px] font-semibold uppercase tracking-wide">
{translateExpertCategory(category)}
</DropdownMenuLabel>
{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 (
<DropdownMenuItem
key={expert.metadata.id}
onClick={() => handleExpertPopoverSelect(expert)}
className="items-start gap-2"
>
<Icon className="mt-0.5 size-4 shrink-0 text-primary/80" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{name}</div>
{description && (
<div className="line-clamp-2 text-xs text-muted-foreground">
{description}
</div>
)}
</div>
</DropdownMenuItem>
)
})}
</div>
))
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -1527,7 +1801,11 @@ export function MessageInput({
<DropdownMenuContent
side="top"
align="start"
className="min-w-72"
className="min-w-72 overflow-y-auto"
style={{
maxHeight:
"min(32rem, var(--radix-dropdown-menu-content-available-height))",
}}
>
{slashCommands.map((cmd) => (
<DropdownMenuItem

View File

@@ -0,0 +1,84 @@
"use client"
import { useEffect, useState } from "react"
import { expertsList } from "@/lib/api"
import type { ExpertListItem } from "@/lib/types"
// Module-level cache so every MessageInput/ChatInput instance shares a single
// fetch. Experts are bundled into the binary and change only when codeg is
// upgraded, so refetching per mount is wasted work.
let cachedExperts: ExpertListItem[] | null = null
let inflight: Promise<ExpertListItem[]> | null = null
const subscribers = new Set<(experts: ExpertListItem[]) => void>()
async function loadExperts(): Promise<ExpertListItem[]> {
if (cachedExperts) return cachedExperts
if (inflight) return inflight
inflight = expertsList()
.then((list) => {
cachedExperts = list
inflight = null
for (const subscriber of subscribers) {
subscriber(list)
}
return list
})
.catch((err) => {
inflight = null
throw err
})
return inflight
}
/**
* Returns the list of built-in expert skills bundled into codeg.
*
* The first call triggers a single backend request; subsequent hook
* instances read from an in-memory cache. Safe to call from many components
* without causing duplicate fetches.
*/
export function useBuiltInExperts(): ExpertListItem[] {
const [experts, setExperts] = useState<ExpertListItem[]>(
() => cachedExperts ?? []
)
useEffect(() => {
// If the cache is already populated the useState initializer above
// already handed us the right value — no follow-up setState needed.
// Only kick off a fetch when the cache is empty, and always register
// the subscriber so concurrent consumers pick up the fresh list the
// moment the first load resolves.
let cancelled = false
if (!cachedExperts) {
loadExperts()
.then((list) => {
if (!cancelled) setExperts(list)
})
.catch((err) => {
console.warn("[useBuiltInExperts] failed to load experts:", err)
})
}
const onUpdate = (next: ExpertListItem[]) => {
if (!cancelled) setExperts(next)
}
subscribers.add(onUpdate)
return () => {
cancelled = true
subscribers.delete(onUpdate)
}
}, [])
return experts
}
/**
* Invalidate the cached experts list. Call this after installing/uninstalling
* experts so subsequent consumers see the fresh list.
*/
export function invalidateBuiltInExperts(): void {
cachedExperts = null
inflight = null
}

View File

@@ -1439,7 +1439,9 @@
"cancel": "إلغاء",
"send": "إرسال",
"forkAndSend": "تفريع وإرسال",
"slashCommands": "أوامر الشرطة المائلة"
"slashCommands": "أوامر الشرطة المائلة",
"expertSkills": "مهارات الخبراء",
"expertsEmptyForAgent": "لا يحتوي هذا العميل على خبراء مفعّلين. فعّلهم من الإعدادات > الخبراء."
},
"messageQueue": {
"addToQueue": "إضافة للقائمة",

View File

@@ -1439,7 +1439,9 @@
"cancel": "Abbrechen",
"send": "Senden",
"forkAndSend": "Fork & Senden",
"slashCommands": "Slash-Befehle"
"slashCommands": "Slash-Befehle",
"expertSkills": "Expertenfähigkeiten",
"expertsEmptyForAgent": "Dieser Agent hat keine aktivierten Experten. Aktivieren Sie sie unter Einstellungen > Experten."
},
"messageQueue": {
"addToQueue": "Zur Warteschlange",

View File

@@ -1439,7 +1439,9 @@
"cancel": "Cancel",
"send": "Send",
"forkAndSend": "Fork & Send",
"slashCommands": "Slash commands"
"slashCommands": "Slash commands",
"expertSkills": "Expert skills",
"expertsEmptyForAgent": "This agent has no enabled experts. Enable them in Settings > Experts."
},
"messageQueue": {
"addToQueue": "Queue message",

View File

@@ -1439,7 +1439,9 @@
"cancel": "Cancelar",
"send": "Enviar",
"forkAndSend": "Fork y Enviar",
"slashCommands": "Comandos de barra"
"slashCommands": "Comandos de barra",
"expertSkills": "Habilidades de expertos",
"expertsEmptyForAgent": "Este agente no tiene expertos habilitados. Actívalos en Configuración > Expertos."
},
"messageQueue": {
"addToQueue": "Agregar a la cola",

View File

@@ -1439,7 +1439,9 @@
"cancel": "Annuler",
"send": "Envoyer",
"forkAndSend": "Fork & Envoyer",
"slashCommands": "Commandes slash"
"slashCommands": "Commandes slash",
"expertSkills": "Compétences d'expert",
"expertsEmptyForAgent": "Cet agent n'a aucun expert activé. Activez-les dans Paramètres > Experts."
},
"messageQueue": {
"addToQueue": "Mettre en file",

View File

@@ -1439,7 +1439,9 @@
"cancel": "キャンセル",
"send": "送信",
"forkAndSend": "フォークして送信",
"slashCommands": "スラッシュコマンド"
"slashCommands": "スラッシュコマンド",
"expertSkills": "エキスパートスキル",
"expertsEmptyForAgent": "このエージェントで有効なエキスパートはありません。「設定 > エキスパート」から有効にしてください。"
},
"messageQueue": {
"addToQueue": "キューに追加",

View File

@@ -1439,7 +1439,9 @@
"cancel": "취소",
"send": "보내기",
"forkAndSend": "포크 & 전송",
"slashCommands": "슬래시 명령"
"slashCommands": "슬래시 명령",
"expertSkills": "전문가 스킬",
"expertsEmptyForAgent": "이 에이전트에 활성화된 전문가가 없습니다. 설정 > 전문가에서 활성화하세요."
},
"messageQueue": {
"addToQueue": "대기열에 추가",

View File

@@ -1439,7 +1439,9 @@
"cancel": "Cancelar",
"send": "Enviar",
"forkAndSend": "Fork & Enviar",
"slashCommands": "Comandos de barra"
"slashCommands": "Comandos de barra",
"expertSkills": "Habilidades de especialistas",
"expertsEmptyForAgent": "Este agente não tem especialistas ativados. Ative-os em Configurações > Especialistas."
},
"messageQueue": {
"addToQueue": "Adicionar à fila",

View File

@@ -1439,7 +1439,9 @@
"cancel": "取消",
"send": "发送",
"forkAndSend": "分叉发送",
"slashCommands": "斜杠命令"
"slashCommands": "斜杠命令",
"expertSkills": "专家技能",
"expertsEmptyForAgent": "该智能体没有启用任何专家。请在「设置 > 专家」中启用。"
},
"messageQueue": {
"addToQueue": "加入队列",

View File

@@ -1439,7 +1439,9 @@
"cancel": "取消",
"send": "傳送",
"forkAndSend": "分叉發送",
"slashCommands": "斜線命令"
"slashCommands": "斜線命令",
"expertSkills": "專家技能",
"expertsEmptyForAgent": "該智慧代理沒有啟用任何專家。請在「設定 > 專家」中啟用。"
},
"messageQueue": {
"addToQueue": "加入佇列",