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:
69
src/components/chat/experts-command-menu.tsx
Normal file
69
src/components/chat/experts-command-menu.tsx
Normal 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] ?? ""
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user