feat(06-04): improve @ mention trigger and anchored candidate panel

- trigger mention candidates when typing @ in any input position
- keep focus and input expansion after selecting a candidate
- anchor candidate panel to textarea area and update e2e selector assertions
This commit is contained in:
肖应宇 2026-04-15 11:39:39 +08:00
parent ce731aff30
commit 5dd13df45f
2 changed files with 127 additions and 93 deletions

View File

@ -71,6 +71,7 @@ import { useUploadedFiles } from "@/core/uploads/hooks";
import { useIframeSkill } from "@/hooks/use-iframe-skill";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { urlOfArtifact } from "@/core/artifacts/utils";
import {
ModelSelector,
@ -82,12 +83,6 @@ import {
ModelSelectorTrigger,
} from "../ai-elements/model-selector";
import { Suggestion, Suggestions } from "../ai-elements/suggestion";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
@ -113,11 +108,15 @@ function getPathTail(path: string | undefined): string {
function findMentionToken(text: string, caret: number) {
const uptoCaret = text.slice(0, caret);
const match = /(?:^|\s)@([^\s@]*)$/.exec(uptoCaret);
if (!match) {
const atIndex = uptoCaret.lastIndexOf("@");
if (atIndex < 0) {
return null;
}
return { query: match[1] ?? "", start: match.index + match[0].indexOf("@") };
const query = uptoCaret.slice(atIndex + 1);
if (/\s/.test(query)) {
return null;
}
return { query, start: atIndex, end: caret };
}
export function InputBox({
@ -185,6 +184,10 @@ export function InputBox({
const [mentionQuery, setMentionQuery] = useState("");
const [mentionOpen, setMentionOpen] = useState(false);
const [activeMentionIndex, setActiveMentionIndex] = useState(0);
const [mentionRange, setMentionRange] = useState<{
start: number;
end: number;
} | null>(null);
const { data: uploadedFilesData } = useUploadedFiles(threadIdFromProps);
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
@ -340,17 +343,24 @@ export function InputBox({
});
const current = textInput.value ?? "";
const token = findMentionToken(current, current.length);
if (token) {
const before = current.slice(0, token.start);
const after = current.slice(current.length);
textInput.setInput(`${before}${after}`.trimEnd());
const range = mentionRange ?? findMentionToken(current, current.length);
if (range) {
const before = current.slice(0, range.start);
const after = current.slice(range.end);
const nextInput = `${before}${after}`;
textInput.setInput(nextInput);
requestAnimationFrame(() => {
textareaRef.current?.focus();
textareaRef.current?.setSelectionRange(before.length, before.length);
});
}
setMentionQuery("");
setMentionOpen(false);
setActiveMentionIndex(0);
setMentionRange(null);
setIsFocused(true);
},
[textInput],
[mentionRange, textInput],
);
const handleTextareaChange = useCallback(
@ -362,9 +372,11 @@ export function InputBox({
setMentionOpen(false);
setMentionQuery("");
setActiveMentionIndex(0);
setMentionRange(null);
return;
}
setMentionQuery(token.query);
setMentionRange({ start: token.start, end: token.end });
setMentionOpen(true);
setActiveMentionIndex(0);
},
@ -408,6 +420,7 @@ export function InputBox({
} else if (event.key === "Escape") {
event.preventDefault();
setMentionOpen(false);
setMentionRange(null);
}
},
[
@ -494,45 +507,6 @@ export function InputBox({
}}
className="relative w-full"
>
{references.length > 0 && (
<div className="absolute bottom-full left-0 z-20 mb-1 flex max-w-full flex-wrap gap-2">
{references.map((reference) => {
const label = reference.path
? `${reference.filename} · ${getPathTail(reference.path)}`
: reference.filename;
return (
<Tag
key={`${reference.ref_source}:${reference.path ?? reference.filename}`}
className="max-w-[260px] shrink-0"
>
<span className="truncate" title={label}>
{label}
</span>
<button
aria-label="移除引用"
className="ml-1 rounded-full"
onClick={() =>
setReferences((prev) =>
prev.filter(
(item) =>
!(
item.ref_source === reference.ref_source &&
item.path === reference.path &&
item.filename === reference.filename
),
),
)
}
type="button"
>
<XIcon className="size-3" />
</button>
</Tag>
);
})}
</div>
)}
<AttachmentPreviewBar />
{extraHeader && (
@ -560,8 +534,72 @@ export function InputBox({
{...props}
>
<PromptInputBody
className={cn("transition-[opacity,transform] duration-300 ease-out")}
className={cn(
"relative transition-[opacity,transform] duration-300 ease-out",
)}
>
{references.length > 0 && (
<div
className="flex max-w-full flex-wrap gap-2 px-2 pt-2"
data-testid="reference-inline-preview"
>
{references.map((reference) => {
const label = reference.path
? `${reference.filename} · ${getPathTail(reference.path)}`
: reference.filename;
const referenceUrl =
threadId && reference.path
? urlOfArtifact({
filepath: reference.path,
threadId,
})
: null;
const isImageReference = /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(
reference.filename,
);
return (
<div
key={`${reference.ref_source}:${reference.path ?? reference.filename}`}
className="bg-background flex h-12 max-w-[280px] items-center gap-2 rounded-lg border px-2"
>
{isImageReference && referenceUrl ? (
<img
src={referenceUrl}
alt={reference.filename}
className="size-8 rounded object-cover"
/>
) : (
<div className="bg-muted flex size-8 items-center justify-center rounded">
<PaperclipIcon className="size-4" />
</div>
)}
<span className="truncate text-xs" title={label}>
{label}
</span>
<button
aria-label="移除引用"
className="text-muted-foreground hover:text-foreground rounded-full"
onClick={() =>
setReferences((prev) =>
prev.filter(
(item) =>
!(
item.ref_source === reference.ref_source &&
item.path === reference.path &&
item.filename === reference.filename
),
),
)
}
type="button"
>
<XIcon className="size-3" />
</button>
</div>
);
})}
</div>
)}
<PromptInputTextarea
ref={textareaRef}
className={cn(
@ -576,42 +614,34 @@ export function InputBox({
onChange={handleTextareaChange}
onKeyDown={handleTextareaKeyDown}
/>
{mentionOpen && filteredMentionCandidates.length > 0 && (
<div
className="bg-popover absolute right-2 bottom-full left-2 z-30 mb-1 rounded-lg border p-1 shadow-md"
data-testid="mention-candidate-panel"
>
{filteredMentionCandidates.slice(0, 20).map((candidate, index) => {
const detail = candidate.pathTail || candidate.ref_source;
return (
<button
key={candidate.key}
type="button"
className={cn(
"hover:bg-accent flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm",
index === activeMentionIndex && "bg-accent",
)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => selectMentionCandidate(candidate)}
>
<span className="truncate">{candidate.filename}</span>
<span className="text-muted-foreground ml-4 shrink-0 text-xs">
{detail}
</span>
</button>
);
})}
</div>
)}
</PromptInputBody>
<DropdownMenu
open={mentionOpen && filteredMentionCandidates.length > 0}
onOpenChange={setMentionOpen}
>
<DropdownMenuTrigger asChild>
<button className="hidden" type="button" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={6}
className="w-[360px] p-2"
>
{filteredMentionCandidates.slice(0, 20).map((candidate, index) => {
const detail = candidate.pathTail || candidate.ref_source;
return (
<DropdownMenuItem
key={candidate.key}
className={cn(
"flex items-center justify-between rounded-md",
index === activeMentionIndex && "bg-accent",
)}
onSelect={(event) => {
event.preventDefault();
selectMentionCandidate(candidate);
}}
>
<span className="truncate">{candidate.filename}</span>
<span className="text-muted-foreground ml-4 shrink-0 text-xs">
{detail}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{!effectiveIsFocused && (
<div
className="absolute inset-0 z-1 cursor-text"

View File

@ -130,14 +130,18 @@ test.describe("聊天工作台 / 输入区与发送", () => {
const textarea = page.locator("textarea[name='message']");
await textarea.click();
await textarea.fill("@");
await textarea.fill("请基于这个文件回答 @");
const dropdown = page.locator("[data-slot='dropdown-menu-content']").first();
const items = dropdown.locator("[data-slot='dropdown-menu-item']");
const panel = page.getByTestId("mention-candidate-panel").first();
await expect(panel).toBeVisible();
const items = panel.locator("button");
const itemCount = await items.count();
testInfo.skip(itemCount === 0, "当前线程没有可引用文件候选。");
await items.first().click();
await expect(textarea).toBeFocused();
await expect(textarea).toHaveValue(/请基于这个文件回答/);
await expect(page.getByTestId("reference-inline-preview")).toBeVisible();
await expect(page.getByLabel("移除引用").first()).toBeVisible();
});