feat(chat): add slash command dropdown button in message input toolbar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-04 13:24:26 +08:00
parent 576944ac1f
commit 6359651247
12 changed files with 85 additions and 17 deletions

View File

@@ -20,6 +20,7 @@ import {
ListPlus, ListPlus,
Plus, Plus,
Send, Send,
Command,
Square, Square,
X, X,
} from "lucide-react" } from "lucide-react"
@@ -54,6 +55,7 @@ import { ModeSelector } from "@/components/chat/mode-selector"
import { SessionConfigSelector } from "@/components/chat/session-config-selector" import { SessionConfigSelector } from "@/components/chat/session-config-selector"
import { SlashCommandMenu } from "@/components/chat/slash-command-menu" import { SlashCommandMenu } from "@/components/chat/slash-command-menu"
import { FileMentionMenu } from "@/components/chat/file-mention-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 { useFileTree } from "@/hooks/use-file-tree"
import { joinFsPath } from "@/lib/path-utils" import { joinFsPath } from "@/lib/path-utils"
import { import {
@@ -314,6 +316,7 @@ export function MessageInput({
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const lastDomDropAtRef = useRef(0) const lastDomDropAtRef = useRef(0)
const composingRef = useRef(false) const composingRef = useRef(false)
const cursorPosRef = useRef<number | null>(null)
const textRef = useRef(text) const textRef = useRef(text)
const disabledRef = useRef(disabled) const disabledRef = useRef(disabled)
const isPromptingRef = useRef(isPrompting) const isPromptingRef = useRef(isPrompting)
@@ -778,6 +781,24 @@ export function MessageInput({
setSlashMenuOpen(false) setSlashMenuOpen(false)
}, []) }, [])
const handleSlashPopoverSelect = useCallback((cmd: AvailableCommandInfo) => {
const pos = cursorPosRef.current ?? textRef.current.length
const before = textRef.current.slice(0, pos)
const after = textRef.current.slice(pos)
const needsSpace = pos > 0 && !/\s$/.test(before)
const insertion = `${needsSpace ? " " : ""}/${cmd.name} `
const newText = before + insertion + after
setText(newText)
requestAnimationFrame(() => {
const ta = textareaRef.current
if (ta) {
ta.focus()
const newPos = pos + insertion.length
ta.setSelectionRange(newPos, newPos)
}
})
}, [])
const atTriggerPosRef = useRef(atTriggerPos) const atTriggerPosRef = useRef(atTriggerPos)
useEffect(() => { useEffect(() => {
atTriggerPosRef.current = atTriggerPos atTriggerPosRef.current = atTriggerPos
@@ -1476,26 +1497,60 @@ export function MessageInput({
autoFocus={autoFocus} autoFocus={autoFocus}
/> />
<div className="@container flex shrink-0 items-end justify-between gap-2 px-2 pb-2"> <div className="@container flex shrink-0 items-end justify-between gap-2 px-2 pb-2">
<div className="flex min-w-0 items-end gap-1"> <div className="flex min-w-0 items-end gap-2">
<Button <Button
onClick={handlePickFiles} onClick={handlePickFiles}
disabled={disabled} disabled={disabled}
variant="ghost" variant="outline"
size="icon" size="icon"
className="h-6 w-6 shrink-0" className="h-6 w-6 shrink-0 bg-transparent"
title={t("attachFiles")} title={t("attachFiles")}
> >
<Plus className="size-4" /> <Plus className="size-4" />
</Button> </Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={disabled || slashCommands.length === 0}
variant="outline"
size="icon"
className="h-6 w-6 shrink-0 bg-transparent"
onPointerDown={() => {
cursorPosRef.current =
textareaRef.current?.selectionStart ?? null
}}
title={t("slashCommands")}
>
<Command className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
className="min-w-72"
>
{slashCommands.map((cmd) => (
<DropdownMenuItem
key={cmd.name}
onClick={() => handleSlashPopoverSelect(cmd)}
>
<DropdownRadioItemContent
label={`/${cmd.name}`}
description={cmd.description}
/>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* 宽屏内联显示,窄屏(<300px通过"更多"气泡显示 */} {/* 宽屏内联显示,窄屏(<300px通过"更多"气泡显示 */}
<div className="hidden @[300px]:contents">{selectorItems}</div> <div className="hidden @[300px]:contents">{selectorItems}</div>
{hasAnySelector && ( {hasAnySelector && (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="ghost" variant="outline"
size="icon" size="icon"
className="h-6 w-6 shrink-0 @[300px]:hidden" className="h-6 w-6 shrink-0 bg-transparent @[300px]:hidden"
> >
<Ellipsis className="size-4" /> <Ellipsis className="size-4" />
</Button> </Button>

View File

@@ -37,9 +37,12 @@ export function SessionConfigSelector({
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="outline"
size="xs" size="xs"
className={cn("gap-1 min-w-0", isActive && "text-primary")} className={cn(
"gap-1 min-w-0 bg-transparent",
isActive && "text-primary"
)}
title={option.description ?? option.name} title={option.description ?? option.name}
> >
<span className="truncate">{label}</span> <span className="truncate">{label}</span>

View File

@@ -1421,7 +1421,8 @@
"loadingMode": "جارٍ تحميل الوضع...", "loadingMode": "جارٍ تحميل الوضع...",
"cancel": "إلغاء", "cancel": "إلغاء",
"send": "إرسال", "send": "إرسال",
"forkAndSend": "تفريع وإرسال" "forkAndSend": "تفريع وإرسال",
"slashCommands": "أوامر الشرطة المائلة"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "إضافة للقائمة", "addToQueue": "إضافة للقائمة",

View File

@@ -1421,7 +1421,8 @@
"loadingMode": "Modus wird geladen...", "loadingMode": "Modus wird geladen...",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"send": "Senden", "send": "Senden",
"forkAndSend": "Fork & Senden" "forkAndSend": "Fork & Senden",
"slashCommands": "Slash-Befehle"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "Zur Warteschlange", "addToQueue": "Zur Warteschlange",

View File

@@ -1421,7 +1421,8 @@
"loadingMode": "Loading mode...", "loadingMode": "Loading mode...",
"cancel": "Cancel", "cancel": "Cancel",
"send": "Send", "send": "Send",
"forkAndSend": "Fork & Send" "forkAndSend": "Fork & Send",
"slashCommands": "Slash commands"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "Queue message", "addToQueue": "Queue message",

View File

@@ -1421,7 +1421,8 @@
"loadingMode": "Cargando modo...", "loadingMode": "Cargando modo...",
"cancel": "Cancelar", "cancel": "Cancelar",
"send": "Enviar", "send": "Enviar",
"forkAndSend": "Fork y Enviar" "forkAndSend": "Fork y Enviar",
"slashCommands": "Comandos de barra"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "Agregar a la cola", "addToQueue": "Agregar a la cola",

View File

@@ -1421,7 +1421,8 @@
"loadingMode": "Chargement du mode...", "loadingMode": "Chargement du mode...",
"cancel": "Annuler", "cancel": "Annuler",
"send": "Envoyer", "send": "Envoyer",
"forkAndSend": "Fork & Envoyer" "forkAndSend": "Fork & Envoyer",
"slashCommands": "Commandes slash"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "Mettre en file", "addToQueue": "Mettre en file",

View File

@@ -1421,7 +1421,8 @@
"loadingMode": "モードを読み込み中...", "loadingMode": "モードを読み込み中...",
"cancel": "キャンセル", "cancel": "キャンセル",
"send": "送信", "send": "送信",
"forkAndSend": "フォークして送信" "forkAndSend": "フォークして送信",
"slashCommands": "スラッシュコマンド"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "キューに追加", "addToQueue": "キューに追加",

View File

@@ -1421,7 +1421,8 @@
"loadingMode": "모드 불러오는 중...", "loadingMode": "모드 불러오는 중...",
"cancel": "취소", "cancel": "취소",
"send": "보내기", "send": "보내기",
"forkAndSend": "포크 & 전송" "forkAndSend": "포크 & 전송",
"slashCommands": "슬래시 명령"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "대기열에 추가", "addToQueue": "대기열에 추가",

View File

@@ -1421,7 +1421,8 @@
"loadingMode": "Carregando modo...", "loadingMode": "Carregando modo...",
"cancel": "Cancelar", "cancel": "Cancelar",
"send": "Enviar", "send": "Enviar",
"forkAndSend": "Fork & Enviar" "forkAndSend": "Fork & Enviar",
"slashCommands": "Comandos de barra"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "Adicionar à fila", "addToQueue": "Adicionar à fila",

View File

@@ -1421,7 +1421,8 @@
"loadingMode": "正在加载模式...", "loadingMode": "正在加载模式...",
"cancel": "取消", "cancel": "取消",
"send": "发送", "send": "发送",
"forkAndSend": "分叉发送" "forkAndSend": "分叉发送",
"slashCommands": "斜杠命令"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "加入队列", "addToQueue": "加入队列",

View File

@@ -1421,7 +1421,8 @@
"loadingMode": "正在載入模式...", "loadingMode": "正在載入模式...",
"cancel": "取消", "cancel": "取消",
"send": "傳送", "send": "傳送",
"forkAndSend": "分叉發送" "forkAndSend": "分叉發送",
"slashCommands": "斜線命令"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "加入佇列", "addToQueue": "加入佇列",