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 { useIframeSkill } from "@/hooks/use-iframe-skill";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ModelSelector,
|
ModelSelector,
|
||||||
|
|
@ -82,12 +83,6 @@ import {
|
||||||
ModelSelectorTrigger,
|
ModelSelectorTrigger,
|
||||||
} from "../ai-elements/model-selector";
|
} from "../ai-elements/model-selector";
|
||||||
import { Suggestion, Suggestions } from "../ai-elements/suggestion";
|
import { Suggestion, Suggestions } from "../ai-elements/suggestion";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "../ui/dropdown-menu";
|
|
||||||
|
|
||||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||||
import { Tooltip } from "./tooltip";
|
import { Tooltip } from "./tooltip";
|
||||||
|
|
@ -113,11 +108,15 @@ function getPathTail(path: string | undefined): string {
|
||||||
|
|
||||||
function findMentionToken(text: string, caret: number) {
|
function findMentionToken(text: string, caret: number) {
|
||||||
const uptoCaret = text.slice(0, caret);
|
const uptoCaret = text.slice(0, caret);
|
||||||
const match = /(?:^|\s)@([^\s@]*)$/.exec(uptoCaret);
|
const atIndex = uptoCaret.lastIndexOf("@");
|
||||||
if (!match) {
|
if (atIndex < 0) {
|
||||||
return null;
|
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({
|
export function InputBox({
|
||||||
|
|
@ -185,6 +184,10 @@ export function InputBox({
|
||||||
const [mentionQuery, setMentionQuery] = useState("");
|
const [mentionQuery, setMentionQuery] = useState("");
|
||||||
const [mentionOpen, setMentionOpen] = useState(false);
|
const [mentionOpen, setMentionOpen] = useState(false);
|
||||||
const [activeMentionIndex, setActiveMentionIndex] = useState(0);
|
const [activeMentionIndex, setActiveMentionIndex] = useState(0);
|
||||||
|
const [mentionRange, setMentionRange] = useState<{
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
} | null>(null);
|
||||||
const { data: uploadedFilesData } = useUploadedFiles(threadIdFromProps);
|
const { data: uploadedFilesData } = useUploadedFiles(threadIdFromProps);
|
||||||
|
|
||||||
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
|
// isNewThread 时禁用收缩,始终保持展开(除非已提交消息)
|
||||||
|
|
@ -340,17 +343,24 @@ export function InputBox({
|
||||||
});
|
});
|
||||||
|
|
||||||
const current = textInput.value ?? "";
|
const current = textInput.value ?? "";
|
||||||
const token = findMentionToken(current, current.length);
|
const range = mentionRange ?? findMentionToken(current, current.length);
|
||||||
if (token) {
|
if (range) {
|
||||||
const before = current.slice(0, token.start);
|
const before = current.slice(0, range.start);
|
||||||
const after = current.slice(current.length);
|
const after = current.slice(range.end);
|
||||||
textInput.setInput(`${before}${after}`.trimEnd());
|
const nextInput = `${before}${after}`;
|
||||||
|
textInput.setInput(nextInput);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
textareaRef.current?.setSelectionRange(before.length, before.length);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setMentionQuery("");
|
setMentionQuery("");
|
||||||
setMentionOpen(false);
|
setMentionOpen(false);
|
||||||
setActiveMentionIndex(0);
|
setActiveMentionIndex(0);
|
||||||
|
setMentionRange(null);
|
||||||
|
setIsFocused(true);
|
||||||
},
|
},
|
||||||
[textInput],
|
[mentionRange, textInput],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTextareaChange = useCallback(
|
const handleTextareaChange = useCallback(
|
||||||
|
|
@ -362,9 +372,11 @@ export function InputBox({
|
||||||
setMentionOpen(false);
|
setMentionOpen(false);
|
||||||
setMentionQuery("");
|
setMentionQuery("");
|
||||||
setActiveMentionIndex(0);
|
setActiveMentionIndex(0);
|
||||||
|
setMentionRange(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMentionQuery(token.query);
|
setMentionQuery(token.query);
|
||||||
|
setMentionRange({ start: token.start, end: token.end });
|
||||||
setMentionOpen(true);
|
setMentionOpen(true);
|
||||||
setActiveMentionIndex(0);
|
setActiveMentionIndex(0);
|
||||||
},
|
},
|
||||||
|
|
@ -408,6 +420,7 @@ export function InputBox({
|
||||||
} else if (event.key === "Escape") {
|
} else if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setMentionOpen(false);
|
setMentionOpen(false);
|
||||||
|
setMentionRange(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
|
@ -494,45 +507,6 @@ export function InputBox({
|
||||||
}}
|
}}
|
||||||
className="relative w-full"
|
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 />
|
<AttachmentPreviewBar />
|
||||||
|
|
||||||
{extraHeader && (
|
{extraHeader && (
|
||||||
|
|
@ -560,8 +534,72 @@ export function InputBox({
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<PromptInputBody
|
<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
|
<PromptInputTextarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -576,42 +614,34 @@ export function InputBox({
|
||||||
onChange={handleTextareaChange}
|
onChange={handleTextareaChange}
|
||||||
onKeyDown={handleTextareaKeyDown}
|
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>
|
</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 && (
|
{!effectiveIsFocused && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-1 cursor-text"
|
className="absolute inset-0 z-1 cursor-text"
|
||||||
|
|
|
||||||
|
|
@ -130,14 +130,18 @@ test.describe("聊天工作台 / 输入区与发送", () => {
|
||||||
|
|
||||||
const textarea = page.locator("textarea[name='message']");
|
const textarea = page.locator("textarea[name='message']");
|
||||||
await textarea.click();
|
await textarea.click();
|
||||||
await textarea.fill("@");
|
await textarea.fill("请基于这个文件回答 @");
|
||||||
|
|
||||||
const dropdown = page.locator("[data-slot='dropdown-menu-content']").first();
|
const panel = page.getByTestId("mention-candidate-panel").first();
|
||||||
const items = dropdown.locator("[data-slot='dropdown-menu-item']");
|
await expect(panel).toBeVisible();
|
||||||
|
const items = panel.locator("button");
|
||||||
const itemCount = await items.count();
|
const itemCount = await items.count();
|
||||||
testInfo.skip(itemCount === 0, "当前线程没有可引用文件候选。");
|
testInfo.skip(itemCount === 0, "当前线程没有可引用文件候选。");
|
||||||
|
|
||||||
await items.first().click();
|
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();
|
await expect(page.getByLabel("移除引用").first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue