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:
parent
ce731aff30
commit
5dd13df45f
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue