- {selectedSlashSkill ? (
+ {hasInlineReferences || inlineEditorActive || selectedSlashSkill ? (
{
if (event.target === event.currentTarget) {
focusContentEditableEnd(inlineSkillTextRef.current);
}
}}
>
-
+ {selectedSlashSkill && (
+
+ )}
setTextareaFocused(false)}
onCompositionEnd={() => {
inlineSkillComposingRef.current = false;
+ inlineCompositionEndedAt.current = Date.now();
}}
onCompositionStart={() => {
inlineSkillComposingRef.current = true;
}}
onFocus={() => setTextareaFocused(true)}
+ aria-controls={showMentions ? mentionListId : undefined}
+ aria-activedescendant={
+ showMentions ? activeMentionOption : undefined
+ }
+ onClick={(event) =>
+ updateMentionQuery(
+ readReferenceEditor(event.currentTarget),
+ referenceCaret(event.currentTarget),
+ )
+ }
+ onKeyUp={(event) => {
+ if (
+ ["ArrowLeft", "ArrowRight", "Home", "End"].includes(
+ event.key,
+ )
+ )
+ updateMentionQuery(
+ readReferenceEditor(event.currentTarget),
+ referenceCaret(event.currentTarget),
+ );
+ }}
onInput={handleInlineSkillInput}
onKeyDown={handleInlineSkillKeyDown}
onPaste={handleInlineSkillPaste}
@@ -2555,7 +3207,9 @@ export function InputBox({
role="textbox"
suppressContentEditableWarning
className={cn(
- "outline-none",
+ selectedSlashSkill
+ ? "inline min-h-6 outline-none"
+ : "block min-h-6 outline-none",
"before:text-muted-foreground before:pointer-events-none",
"data-[empty=true]:before:content-[attr(data-placeholder)]",
composerLocked && "cursor-not-allowed opacity-50",
@@ -2571,6 +3225,20 @@ export function InputBox({
autoFocus={autoFocus}
defaultValue={initialValue}
onBlur={() => setTextareaFocused(false)}
+ aria-controls={showMentions ? mentionListId : undefined}
+ aria-activedescendant={
+ showMentions ? activeMentionOption : undefined
+ }
+ onSelect={(event) => {
+ const input = event.currentTarget;
+ if (!mentionButtonOpen && !mentionInFlight.current)
+ updateMentionQuery(
+ input.value,
+ input.selectionStart === input.selectionEnd
+ ? input.selectionStart
+ : null,
+ );
+ }}
onChange={handlePromptTextareaChange}
onFocus={() => setTextareaFocused(true)}
onKeyDown={handlePromptTextareaKeyDown}
@@ -2580,18 +3248,22 @@ export function InputBox({
+
+
+
+
+
-
@@ -3088,7 +3766,9 @@ function AddAttachmentsButton({
className,
disabled,
uploadLimits,
+ onOpen,
}: {
+ onOpen?: () => void;
className?: string;
disabled?: boolean;
uploadLimits?: UploadLimits;
@@ -3109,7 +3789,7 @@ function AddAttachmentsButton({
className={cn("px-2!", className)}
data-testid="add-attachments-button"
disabled={disabled}
- onClick={() => attachments.openFileDialog()}
+ onClick={onOpen ?? (() => attachments.openFileDialog())}
>
diff --git a/frontend/src/components/workspace/mentions/inline-references.ts b/frontend/src/components/workspace/mentions/inline-references.ts
new file mode 100644
index 0000000000..d0a96cd9cd
--- /dev/null
+++ b/frontend/src/components/workspace/mentions/inline-references.ts
@@ -0,0 +1,210 @@
+import type { ConversationReference } from "@/core/conversation-references";
+
+export const MAX_EXPLICIT_SKILLS = 16;
+
+export type InlineReference = {
+ kind: "skill" | "file" | "conversation";
+ id: string;
+ label: string;
+ start: number;
+ end: number;
+};
+
+const pattern = /@\[([^\]]*)\]\(ref:(skill|file|conversation):([^)]*)\)/g;
+
+export function referenceToken(
+ kind: InlineReference["kind"],
+ id: string,
+ label: string,
+) {
+ const encode = (value: string) =>
+ encodeURIComponent(value).replace(
+ /[!'()*]/g,
+ (char) => `%${char.charCodeAt(0).toString(16)}`,
+ );
+ return `@[${encode(label)}](ref:${kind}:${encode(id)})`;
+}
+
+export function inlineReferences(text: string): InlineReference[] {
+ const result: InlineReference[] = [];
+ for (const match of text.matchAll(pattern)) {
+ try {
+ result.push({
+ kind: match[2] as InlineReference["kind"],
+ id: decodeURIComponent(match[3]!),
+ label: decodeURIComponent(match[1]!),
+ start: match.index,
+ end: match.index + match[0].length,
+ });
+ } catch {
+ /* Malformed pasted text remains ordinary text. */
+ }
+ }
+ return result;
+}
+
+export function readableReferences(text: string) {
+ let value = text;
+ for (const reference of inlineReferences(text).reverse()) {
+ value =
+ value.slice(0, reference.start) +
+ `@${reference.label}` +
+ value.slice(reference.end);
+ }
+ return value;
+}
+
+/** Restore display labels in one pass so replacement tokens cannot match again. */
+export function restoreReferenceLabels(original: string, rewritten: string) {
+ const remaining = inlineReferences(original);
+ if (!remaining.length) return rewritten;
+ const labels = [...new Set(remaining.map((ref) => ref.label))]
+ .sort((a, b) => b.length - a.length)
+ .map((label) => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
+ // Longest labels win; a short label cannot consume a longer word or slug.
+ const pattern = new RegExp(
+ `@(?:${labels.join("|")})(?![\\p{L}\\p{M}\\p{N}_-])`,
+ "gu",
+ );
+ const restored = rewritten.replace(pattern, (label) => {
+ const index = remaining.findIndex((ref) => `@${ref.label}` === label);
+ if (index < 0) return label;
+ const [ref] = remaining.splice(index, 1);
+ return referenceToken(ref!.kind, ref!.id, ref!.label);
+ });
+ // A rewrite may omit a label; retain that explicit selection in draft order.
+ return [
+ ...remaining.map((ref) => referenceToken(ref.kind, ref.id, ref.label)),
+ restored,
+ ].join(" ");
+}
+
+export function readReferenceEditor(node: Node): string {
+ if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? "";
+ if (node instanceof HTMLElement) {
+ if (node.dataset.reference) return node.dataset.reference;
+ if (node.tagName === "BR") return "\n";
+ }
+ return Array.from(node.childNodes, readReferenceEditor).join("");
+}
+
+export function referenceCaret(root: HTMLElement): number | null {
+ const selection = window.getSelection();
+ if (!selection?.isCollapsed || !selection.rangeCount) return null;
+ const range = selection.getRangeAt(0);
+ if (!root.contains(range.endContainer)) return null;
+ const before = range.cloneRange();
+ before.selectNodeContents(root);
+ before.setEnd(range.endContainer, range.endOffset);
+ return readReferenceEditor(before.cloneContents()).length;
+}
+
+export function focusReferenceAt(root: HTMLElement, offset: number) {
+ root.focus();
+ const range = document.createRange();
+ range.selectNodeContents(root);
+ range.collapse(false);
+ let remaining = offset;
+ for (const node of root.childNodes) {
+ const length = readReferenceEditor(node).length;
+ if (remaining <= length) {
+ if (node.nodeType === Node.TEXT_NODE) range.setStart(node, remaining);
+ else if (remaining === 0) range.setStartBefore(node);
+ else range.setStartAfter(node);
+ range.collapse(true);
+ break;
+ }
+ remaining -= length;
+ }
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+}
+
+export function renderReferenceEditor(root: HTMLElement, text: string) {
+ if (readReferenceEditor(root) === text) return;
+ const caret = referenceCaret(root);
+ const fragment = document.createDocumentFragment();
+ let end = 0;
+ for (const ref of inlineReferences(text)) {
+ fragment.append(document.createTextNode(text.slice(end, ref.start)));
+ const token = document.createElement("span");
+ token.contentEditable = "false";
+ token.dataset.reference = text.slice(ref.start, ref.end);
+ token.dataset.referenceKind = ref.kind;
+ token.dataset.testid =
+ ref.kind === "file"
+ ? "project-attachment-chip"
+ : ref.kind === "conversation"
+ ? "conversation-reference-chip"
+ : "inline-skill-reference";
+ token.className =
+ "inline-flex items-baseline gap-1 align-baseline font-medium select-all " +
+ (ref.kind === "skill"
+ ? "text-blue-600 dark:text-blue-400"
+ : ref.kind === "file"
+ ? "text-rose-600 dark:text-rose-400"
+ : "text-violet-600 dark:text-violet-400");
+ const icon = document.createElement("span");
+ icon.setAttribute("aria-hidden", "true");
+ icon.textContent =
+ ref.kind === "skill" ? "✦" : ref.kind === "file" ? "▤" : "◉";
+ token.append(icon, document.createTextNode(ref.label));
+ token.setAttribute("aria-label", `@${ref.label}`);
+ fragment.append(token);
+ end = ref.end;
+ }
+ fragment.append(document.createTextNode(text.slice(end)));
+ root.replaceChildren(fragment);
+ if (caret !== null) focusReferenceAt(root, Math.min(caret, text.length));
+}
+
+export function reconcileConversationReferences(
+ text: string,
+ known: ConversationReference[],
+ capability: {
+ enabled: boolean;
+ maxReferences: number;
+ isLoading: boolean;
+ isSuccess: boolean;
+ },
+ threadId: string,
+): { text: string; references: ConversationReference[] } {
+ // Only a successful discovery may destructively normalize the draft.
+ // Preserve known metadata while pending/failed, including across editor changes.
+ if (!capability.isSuccess || capability.isLoading) {
+ const ids = new Set(
+ inlineReferences(text)
+ .filter((ref) => ref.kind === "conversation")
+ .map((ref) => ref.id),
+ );
+ return { text, references: known.filter((ref) => ids.has(ref.threadId)) };
+ }
+ const references = new Map
();
+ const limit = capability.enabled ? capability.maxReferences : 0;
+ const tokens = inlineReferences(text).filter(
+ (ref) => ref.kind === "conversation",
+ );
+ for (const ref of tokens) {
+ if (
+ !ref.id ||
+ ref.id === threadId ||
+ references.has(ref.id) ||
+ references.size >= limit
+ )
+ continue;
+ references.set(
+ ref.id,
+ known.find((item) => item.threadId === ref.id) ?? {
+ threadId: ref.id,
+ title: ref.label,
+ },
+ );
+ }
+ // Disabled, self, and over-limit references become honest ordinary text.
+ for (const ref of tokens.reverse()) {
+ if (!references.has(ref.id))
+ text = text.slice(0, ref.start) + `@${ref.label}` + text.slice(ref.end);
+ }
+ return { text, references: [...references.values()] };
+}
diff --git a/frontend/src/components/workspace/mentions/mention-picker.tsx b/frontend/src/components/workspace/mentions/mention-picker.tsx
new file mode 100644
index 0000000000..899dfffcf3
--- /dev/null
+++ b/frontend/src/components/workspace/mentions/mention-picker.tsx
@@ -0,0 +1,420 @@
+"use client";
+
+import {
+ CheckIcon,
+ FileIcon,
+ MessagesSquareIcon,
+ SparklesIcon,
+ UploadIcon,
+ XIcon,
+} from "lucide-react";
+import {
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+ type KeyboardEvent,
+ type Ref,
+} from "react";
+
+import type { ConversationReference } from "@/core/conversation-references";
+import { useI18n } from "@/core/i18n/hooks";
+import { useInfiniteProjectDocuments } from "@/core/projects/hooks";
+import type { ProjectDocument } from "@/core/projects/types";
+import type { Skill } from "@/core/skills/type";
+import { useInfiniteThreads } from "@/core/threads/hooks";
+import { agentNameOfThread, titleOfThread } from "@/core/threads/utils";
+import { isCompositionConfirmEnter, isIMEComposing } from "@/lib/ime";
+import { cn } from "@/lib/utils";
+
+import { MAX_EXPLICIT_SKILLS } from "./inline-references";
+
+export type MentionSelection =
+ | { kind: "skill"; skill: Skill }
+ | { kind: "file"; document: ProjectDocument }
+ | { kind: "conversation"; reference: ConversationReference }
+ | { kind: "upload" };
+export type MentionPickerHandle = {
+ onKeyDown: (event: KeyboardEvent) => void;
+};
+type Option = {
+ id: string;
+ label: string;
+ description?: string;
+ selection: MentionSelection;
+ disabled?: boolean;
+ selected?: boolean;
+};
+
+export function MentionPicker({
+ ref,
+ query,
+ searchInput = false,
+ skills,
+ skillsLoading = false,
+ skillsError,
+ onRetrySkills,
+ capability,
+ onActiveOptionChange,
+ selectedSkills,
+ references,
+ threadId,
+ projectId,
+ busy,
+ error,
+ onSelect,
+ onClose,
+ listId,
+}: {
+ ref?: Ref;
+ query: string;
+ searchInput?: boolean;
+ skills: Skill[];
+ skillsLoading?: boolean;
+ skillsError?: unknown;
+ onRetrySkills?: () => unknown;
+ capability: {
+ enabled: boolean;
+ maxReferences: number;
+ isLoading: boolean;
+ error?: unknown;
+ refetch?: () => unknown;
+ };
+ onActiveOptionChange?: (id: string | undefined) => void;
+ selectedSkills?: string[];
+ references: ConversationReference[];
+ threadId: string;
+ projectId?: string | null;
+ busy: boolean;
+ error: string | null;
+ onSelect: (selection: MentionSelection) => void;
+ onClose: () => void;
+ listId: string;
+}) {
+ const { t } = useI18n();
+ const labels = t.inputBox;
+ const documents = useInfiniteProjectDocuments(projectId ?? "", {
+ enabled: !!projectId,
+ });
+ const conversations = useInfiniteThreads(undefined, {
+ enabled: capability.enabled,
+ });
+ const [search, setSearch] = useState("");
+ const [activeId, setActiveId] = useState(null);
+ const rootRef = useRef(null);
+ const compositionEndedAt = useRef(-Infinity);
+ const filter = (searchInput ? search : query).toLocaleLowerCase();
+ const options = useMemo(() => {
+ const result: Option[] = skills
+ .filter((skill) => skill.enabled)
+ .map((skill) => ({
+ id: `skill:${skill.name}`,
+ label: skill.name,
+ description: skill.description,
+ selection: { kind: "skill", skill },
+ selected: selectedSkills?.includes(skill.name),
+ disabled:
+ !selectedSkills?.includes(skill.name) &&
+ new Set(selectedSkills).size >= MAX_EXPLICIT_SKILLS,
+ }));
+ for (const document of documents.data?.pages.flatMap(
+ (page) => page.documents,
+ ) ?? []) {
+ result.push({
+ id: `file:${document.id}`,
+ label: document.name,
+ description: document.content_missing
+ ? labels.mentionUnavailable
+ : undefined,
+ selection: { kind: "file", document },
+ disabled: document.content_missing,
+ });
+ }
+ if (capability.enabled) {
+ const seen = new Set();
+ for (const thread of conversations.data?.pages.flat() ?? []) {
+ if (thread.thread_id === threadId || seen.has(thread.thread_id))
+ continue;
+ seen.add(thread.thread_id);
+ const selected = references.some(
+ (item) => item.threadId === thread.thread_id,
+ );
+ result.push({
+ id: `conversation:${thread.thread_id}`,
+ label: titleOfThread(thread),
+ selection: {
+ kind: "conversation",
+ reference: {
+ threadId: thread.thread_id,
+ title: titleOfThread(thread),
+ agentName: agentNameOfThread(thread),
+ },
+ },
+ selected,
+ disabled: !selected && references.length >= capability.maxReferences,
+ });
+ }
+ }
+ const filtered = result.filter((option) =>
+ `${option.label} ${option.description ?? ""}`
+ .toLocaleLowerCase()
+ .includes(filter),
+ );
+ filtered.push({
+ id: "upload",
+ label: labels.mentionUpload,
+ selection: { kind: "upload" },
+ });
+ return filtered;
+ }, [
+ skills,
+ selectedSkills,
+ documents.data,
+ conversations.data,
+ capability.enabled,
+ capability.maxReferences,
+ threadId,
+ references,
+ filter,
+ labels,
+ ]);
+ const available = options.filter((option) => !option.disabled);
+ const active =
+ available.find((option) => option.id === activeId) ??
+ (filter
+ ? available.find((option) => option.selection.kind !== "upload")
+ : available[0]);
+ const optionId = (id: string) => `${listId}-${encodeURIComponent(id)}`;
+ const activeOptionId = active ? optionId(active.id) : undefined;
+ useEffect(() => {
+ onActiveOptionChange?.(searchInput ? undefined : activeOptionId);
+ }, [activeOptionId, searchInput, onActiveOptionChange]);
+ useEffect(
+ () => () => onActiveOptionChange?.(undefined),
+ [onActiveOptionChange],
+ );
+ const choose = (option: Option) => {
+ if (!busy && !option.disabled) onSelect(option.selection);
+ };
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (isCompositionConfirmEnter(event, compositionEndedAt.current)) {
+ event.preventDefault();
+ return;
+ }
+ if (isIMEComposing(event)) return;
+ if (event.key === "Escape") {
+ event.preventDefault();
+ onClose();
+ return;
+ }
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
+ event.preventDefault();
+ const index = available.findIndex((option) => option.id === active?.id);
+ const next =
+ available[
+ (index + (event.key === "ArrowDown" ? 1 : -1) + available.length) %
+ available.length
+ ];
+ setActiveId(next?.id ?? null);
+ } else if (event.key === "Enter" && !event.shiftKey && active) {
+ event.preventDefault();
+ choose(active);
+ }
+ };
+ useImperativeHandle(ref, () => ({ onKeyDown }));
+ useEffect(() => {
+ if (activeOptionId)
+ document
+ .getElementById(activeOptionId)
+ ?.scrollIntoView?.({ block: "nearest" });
+ }, [activeOptionId]);
+ const loading =
+ skillsLoading ||
+ capability.isLoading ||
+ (!!projectId && documents.isPending) ||
+ (capability.enabled && conversations.isPending);
+ const failed =
+ !!capability.error ||
+ !!skillsError ||
+ (!!projectId && documents.isError) ||
+ (capability.enabled && conversations.isError);
+ const groups = [
+ { kind: "skill", title: labels.mentionSkills, Icon: SparklesIcon },
+ { kind: "file", title: labels.mentionFiles, Icon: FileIcon },
+ {
+ kind: "conversation",
+ title: labels.mentionConversations,
+ Icon: MessagesSquareIcon,
+ },
+ { kind: "upload", title: "", Icon: UploadIcon },
+ ] as const;
+ return (
+
+
+ {searchInput ? (
+ {
+ setSearch(event.target.value);
+ setActiveId(null);
+ }}
+ onKeyDown={onKeyDown}
+ onCompositionEnd={() => {
+ compositionEndedAt.current = Date.now();
+ }}
+ />
+ ) : (
+
+ {labels.mentionPicker}
+
+ )}
+
+
+
+ {groups.map(({ kind, title, Icon }) => {
+ const items = options.filter(
+ (option) => option.selection.kind === kind,
+ );
+ if (!items.length) return null;
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+ {items.map((option) => (
+
+ ))}
+
+ );
+ })}
+
+ {options.length === 1 && !loading && !failed && (
+
+ {labels.mentionEmpty}
+
+ )}
+ {loading && (
+
+ {labels.mentionLoading}
+
+ )}
+ {failed && (
+
+ {labels.mentionFailed}{" "}
+
+
+ )}
+ {(documents.hasNextPage ||
+ (capability.enabled && conversations.hasNextPage)) && (
+
+ )}
+ {!projectId && (
+
+ {labels.mentionNoProject}
+
+ )}
+
+ {labels.mentionMultipleSkills}
+
+ {capability.enabled && (
+
+ {labels.referenceConversationsLimit(capability.maxReferences)}
+
+ )}
+ {busy && (
+
+ {labels.mentionAttaching}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/workspace/mentions/query.ts b/frontend/src/components/workspace/mentions/query.ts
new file mode 100644
index 0000000000..7079dc0241
--- /dev/null
+++ b/frontend/src/components/workspace/mentions/query.ts
@@ -0,0 +1,14 @@
+export type MentionQuery = { start: number; end: number; query: string };
+
+/** Only a standalone @ at the caret opens the picker, never an email or URL. */
+export function getMentionQuery(
+ text: string,
+ caret: number,
+): MentionQuery | null {
+ if (caret < 0 || caret > text.length) return null;
+ const before = text.slice(0, caret);
+ const match = /(?:^|[\s((,,。::])@([^\s@/\\]*)$/u.exec(before);
+ if (!match) return null;
+ const query = match[1] ?? "";
+ return { start: caret - query.length - 1, end: caret, query };
+}
diff --git a/frontend/src/components/workspace/slash-skill-chip.tsx b/frontend/src/components/workspace/slash-skill-chip.tsx
index 61427d6a88..b93e4f3619 100644
--- a/frontend/src/components/workspace/slash-skill-chip.tsx
+++ b/frontend/src/components/workspace/slash-skill-chip.tsx
@@ -3,7 +3,7 @@ import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
/**
- * Shared visual for a `/skill` activation, used both as a removable chip in the
+ * Shared visual for a `@skill` activation, used both as a removable chip in the
* composer and as a read-only chip in the chat transcript. Keeping a single
* source of truth means the two stay in lockstep instead of drifting apart in
* their Tailwind classes.
@@ -16,26 +16,29 @@ export function SlashSkillChip({
className,
onRemove,
removeLabel,
+ disabled = false,
}: {
name: string;
className?: string;
/** When provided, the chip renders as a removable button with a close icon. */
onRemove?: () => void;
removeLabel?: string;
+ disabled?: boolean;
}) {
if (onRemove) {
return (
);
@@ -43,7 +46,7 @@ export function SlashSkillChip({
return (
- /{name}
+ @{name}
);
}
diff --git a/frontend/src/core/features/hooks.ts b/frontend/src/core/features/hooks.ts
index 7c6a3d3742..618fe799e0 100644
--- a/frontend/src/core/features/hooks.ts
+++ b/frontend/src/core/features/hooks.ts
@@ -55,7 +55,7 @@ export function useSubagentBatchesCapability() {
}
export function useConversationReferencesCapability() {
- const { data, isPending } = useQuery({
+ const { data, isPending, isSuccess, error, refetch } = useQuery({
queryKey: ["features", "conversation_references"],
queryFn: () => fetchConversationReferencesCapability(),
staleTime: 0,
@@ -63,9 +63,12 @@ export function useConversationReferencesCapability() {
retry: false,
});
return {
- enabled: data?.enabled ?? false,
+ enabled: isSuccess && (data?.enabled ?? false),
maxReferences: data?.maxReferences ?? 0,
isLoading: isPending,
+ isSuccess,
+ error,
+ refetch,
};
}
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts
index e4f211ccac..1243679f76 100644
--- a/frontend/src/core/i18n/locales/en-US.ts
+++ b/frontend/src/core/i18n/locales/en-US.ts
@@ -347,6 +347,26 @@ export const enUS: Translations = {
// Input Box
inputBox: {
+ mentionPicker: "Add a reference",
+ mentionSearch: "Search skills, project files and conversations",
+ mentionSkills: "Skills",
+ mentionFiles: "Project files",
+ mentionConversations: "Conversations",
+ mentionUpload: "Upload a file",
+ mentionEmpty: "No matching references in loaded results",
+ mentionLoadMore: "Load more",
+ mentionLoading: "Loading references…",
+ mentionFailed: "Could not load references. Try again.",
+ mentionRetry: "Retry",
+ mentionAttaching: "Adding file…",
+ mentionAttachFailed: "Could not add this file. Try again.",
+ mentionMultipleSkills:
+ "Select up to 16 skills per message; select a checked skill again to remove it.",
+ mentionNoProject: "Open a project chat to reference its documents.",
+ mentionRemoveSkill: "Remove skill",
+ mentionUnavailable: "File unavailable",
+ mentionClose: "Close references",
+
placeholder: "How can I assist you today?",
disclaimer: "DeerFlow is AI and can make mistakes",
createSkillPrompt:
diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts
index a8a67142e1..22e6813c26 100644
--- a/frontend/src/core/i18n/locales/types.ts
+++ b/frontend/src/core/i18n/locales/types.ts
@@ -301,6 +301,25 @@ export interface Translations {
// Input Box
inputBox: {
+ mentionPicker: string;
+ mentionSearch: string;
+ mentionSkills: string;
+ mentionFiles: string;
+ mentionConversations: string;
+ mentionUpload: string;
+ mentionEmpty: string;
+ mentionLoadMore: string;
+ mentionLoading: string;
+ mentionFailed: string;
+ mentionRetry: string;
+ mentionAttaching: string;
+ mentionAttachFailed: string;
+ mentionMultipleSkills: string;
+ mentionNoProject: string;
+ mentionRemoveSkill: string;
+ mentionUnavailable: string;
+ mentionClose: string;
+
placeholder: string;
disclaimer: string;
createSkillPrompt: string;
diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts
index bf072fcd15..b685b8e4e1 100644
--- a/frontend/src/core/i18n/locales/zh-CN.ts
+++ b/frontend/src/core/i18n/locales/zh-CN.ts
@@ -330,6 +330,26 @@ export const zhCN: Translations = {
// Input Box
inputBox: {
+ mentionPicker: "添加引用",
+ mentionSearch: "搜索技能、项目文件和对话",
+ mentionSkills: "技能",
+ mentionFiles: "项目文件",
+ mentionConversations: "对话",
+ mentionUpload: "上传文件",
+ mentionEmpty: "已加载的内容中没有匹配的引用",
+ mentionLoadMore: "加载更多",
+ mentionLoading: "正在加载引用…",
+ mentionFailed: "引用加载失败,请重试。",
+ mentionRetry: "重试",
+ mentionAttaching: "正在添加文件…",
+ mentionAttachFailed: "文件添加失败,请重试。",
+ mentionMultipleSkills:
+ "每条消息最多选择 16 个技能,再次选择已勾选技能可移除。",
+ mentionNoProject: "在项目对话中可引用该项目的文档。",
+ mentionRemoveSkill: "移除技能",
+ mentionUnavailable: "文件不可用",
+ mentionClose: "关闭引用面板",
+
placeholder: "今天我能为你做些什么?",
disclaimer: "内容由AI生成,重要信息请务必核查",
createSkillPrompt:
diff --git a/frontend/src/core/projects/types.ts b/frontend/src/core/projects/types.ts
index 10431a602e..04e8688e28 100644
--- a/frontend/src/core/projects/types.ts
+++ b/frontend/src/core/projects/types.ts
@@ -82,6 +82,9 @@ export type PromoteThreadFileInput = {
* the ingested thread upload the composer adds to its attachment list.
*/
export type AttachProjectDocumentResult = {
+ /** Client draft provenance for deduplicating picker selections. */
+ source_document_id?: string;
+ source_project_id?: string;
filename: string;
size_bytes: number;
virtual_path: string;
diff --git a/frontend/src/core/skills/hooks.ts b/frontend/src/core/skills/hooks.ts
index 7b5121dc4e..a224427384 100644
--- a/frontend/src/core/skills/hooks.ts
+++ b/frontend/src/core/skills/hooks.ts
@@ -5,12 +5,12 @@ import { enableSkill, SkillRequestError, uploadSkillArchive } from "./api";
import { loadSkills } from ".";
export function useSkills() {
- const { data, isLoading, error } = useQuery({
+ const { data, isLoading, isFetching, error, refetch } = useQuery({
queryKey: ["skills"],
queryFn: () => loadSkills(),
retry: (count, err) => !(err instanceof SkillRequestError) && count < 3,
});
- return { skills: data ?? [], isLoading, error };
+ return { skills: data ?? [], isLoading, isFetching, error, refetch };
}
export function useEnableSkill() {
diff --git a/frontend/src/core/threads/composer-draft.ts b/frontend/src/core/threads/composer-draft.ts
index 1d71b9baa1..4382469cff 100644
--- a/frontend/src/core/threads/composer-draft.ts
+++ b/frontend/src/core/threads/composer-draft.ts
@@ -1,9 +1,15 @@
+import {
+ readConversationReferences,
+ type ConversationReference,
+} from "@/core/conversation-references";
+
const COMPOSER_DRAFT_VERSION = 1;
const COMPOSER_DRAFT_PREFIX = "deerflow:composer-draft:v1";
export type ComposerDraft = {
text: string;
skillName: string | null;
+ conversationReferences?: ConversationReference[];
};
export type ComposerDraftStorage = Pick<
@@ -56,6 +62,7 @@ export function readComposerDraft(
version?: unknown;
text?: unknown;
skillName?: unknown;
+ conversationReferences?: unknown;
};
if (
parsed.version !== COMPOSER_DRAFT_VERSION ||
@@ -65,7 +72,23 @@ export function readComposerDraft(
return null;
}
+ const references = Array.isArray(parsed.conversationReferences)
+ ? readConversationReferences({
+ conversation_references: parsed.conversationReferences.map(
+ (item: unknown) => {
+ if (!item || typeof item !== "object") return null;
+ const value = item as Record;
+ return {
+ thread_id: value.threadId,
+ title: value.title,
+ agent_name: value.agentName,
+ };
+ },
+ ),
+ })
+ : [];
return {
+ ...(references.length ? { conversationReferences: references } : {}),
text: parsed.text,
skillName: parsed.skillName,
};
@@ -83,7 +106,11 @@ export function writeComposerDraft(
if (!storage) {
return;
}
- if (!draft.text && !draft.skillName) {
+ if (
+ !draft.text &&
+ !draft.skillName &&
+ !draft.conversationReferences?.length
+ ) {
storage.removeItem(key);
return;
}
@@ -94,6 +121,9 @@ export function writeComposerDraft(
version: COMPOSER_DRAFT_VERSION,
text: draft.text,
skillName: draft.skillName,
+ ...(draft.conversationReferences?.length
+ ? { conversationReferences: draft.conversationReferences }
+ : {}),
}),
);
} catch {
@@ -124,6 +154,7 @@ export function resolveComposerDraft(
}
return {
+ ...draft,
text: `/${draft.skillName}${draft.text ? ` ${draft.text}` : ""}`,
skillName: null,
};
diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts
index 5c583085b3..40cf93679e 100644
--- a/frontend/src/core/threads/hooks.ts
+++ b/frontend/src/core/threads/hooks.ts
@@ -3438,6 +3438,7 @@ export function useInfiniteThreads(
sortOrder: "desc",
select: ["thread_id", "updated_at", "values", "metadata"],
},
+ { enabled = true }: { enabled?: boolean } = {},
) {
const apiClient = getAPIClient();
return useInfiniteQuery<
@@ -3459,6 +3460,7 @@ export function useInfiniteThreads(
getNextPageParam: (lastPage, allPages) =>
getInfiniteThreadsNextPageParam(lastPage, allPages),
refetchOnWindowFocus: false,
+ enabled,
});
}
diff --git a/frontend/tests/e2e/chat.spec.ts b/frontend/tests/e2e/chat.spec.ts
index 16cbe552d2..e5f27979b1 100644
--- a/frontend/tests/e2e/chat.spec.ts
+++ b/frontend/tests/e2e/chat.spec.ts
@@ -321,7 +321,7 @@ test.describe("Chat workspace", () => {
await textarea.fill("/dat");
await textarea.press("Enter");
- await expect(page.getByText("/data-analysis")).toBeVisible();
+ await expect(page.getByText("@data-analysis")).toBeVisible();
const skillInput = page.getByRole("textbox", {
name: /how can i assist you/i,
});
@@ -334,7 +334,7 @@ test.describe("Chat workspace", () => {
await page.reload();
- await expect(page.getByText("/data-analysis")).toBeVisible();
+ await expect(page.getByText("@data-analysis")).toBeVisible();
await expect(
page.getByRole("textbox", {
name: /how can i assist you/i,
@@ -452,6 +452,22 @@ test.describe("Chat workspace", () => {
timeout: 10_000,
});
+ // A new user edit must still save, even when it repeats the accepted text.
+ await page
+ .getByPlaceholder(/how can i assist you/i)
+ .fill("Send this immediately");
+ await expect
+ .poll(() =>
+ page.evaluate(() => Object.values(window.sessionStorage).join("\n")),
+ )
+ .toContain("Send this immediately");
+ await page.getByPlaceholder(/how can i assist you/i).fill("");
+ await expect
+ .poll(() =>
+ page.evaluate(() => Object.values(window.sessionStorage).join("\n")),
+ )
+ .not.toContain("Send this immediately");
+
await page.reload();
await expect(page.getByPlaceholder(/how can i assist you/i)).toHaveValue(
"",
@@ -650,7 +666,7 @@ test.describe("Chat workspace", () => {
await textarea.press("Enter");
- await expect(page.getByText("/data-analysis")).toBeVisible();
+ await expect(page.getByText("@data-analysis")).toBeVisible();
const skillInput = page.getByRole("textbox", {
name: /how can i assist you/i,
});
@@ -688,7 +704,7 @@ test.describe("Chat workspace", () => {
page.getByRole("option", { name: /data-analysis/i }),
).toBeVisible();
await textarea.press("Enter");
- await expect(page.getByText("/data-analysis")).toBeVisible();
+ await expect(page.getByText("@data-analysis")).toBeVisible();
const skillInput = page.getByRole("textbox", {
name: /how can i assist you/i,
@@ -712,8 +728,8 @@ test.describe("Chat workspace", () => {
await skillInput.press("Enter");
- await expect(page.getByText("/frontend-design")).toBeVisible();
- await expect(page.getByText("/data-analysis")).toBeHidden();
+ await expect(page.getByText("@frontend-design")).toBeVisible();
+ await expect(page.getByText("@data-analysis")).toBeHidden();
await skillInput.pressSequentially("polish the composer");
await skillInput.press("Enter");
@@ -778,7 +794,7 @@ test.describe("Chat workspace", () => {
page.getByRole("option", { name: /data-analysis/i }),
).toBeVisible();
await textarea.press("Enter");
- await expect(page.getByText("/data-analysis")).toBeVisible();
+ await expect(page.getByText("@data-analysis")).toBeVisible();
const skillInput = page.getByRole("textbox", {
name: /how can i assist you/i,
@@ -934,7 +950,7 @@ test.describe("Chat workspace", () => {
await textarea.press("ArrowDown");
await textarea.press("Enter");
- await expect(page.getByText("/frontend-design")).toBeVisible();
+ await expect(page.getByText("@frontend-design")).toBeVisible();
await expect(
page.getByRole("textbox", { name: /how can i assist you/i }),
).toBeVisible();
diff --git a/frontend/tests/e2e/mentions.spec.ts b/frontend/tests/e2e/mentions.spec.ts
new file mode 100644
index 0000000000..7c7043c697
--- /dev/null
+++ b/frontend/tests/e2e/mentions.spec.ts
@@ -0,0 +1,304 @@
+import { expect, test, type Page } from "@playwright/test";
+
+import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
+
+const composer = (page: Page) =>
+ page.getByRole("textbox", { name: /how can i assist you/i });
+const nextRun = (page: Page) =>
+ page.waitForRequest(
+ (r) => r.method() === "POST" && r.url().includes("/runs/stream"),
+ );
+test.beforeEach(async ({ page }) => {
+ mockLangGraphAPI(page, {
+ threads: [
+ {
+ thread_id: MOCK_THREAD_ID,
+ title: "Writer brief",
+ updated_at: "2026-09-01T00:00:00Z",
+ },
+ ],
+ skills: [
+ { name: "research", description: "Research a topic", enabled: true },
+ { name: "writing", description: "Write a report", enabled: true },
+ ],
+ projects: [{ id: "proj-1", name: "Research project" }],
+ projectDocuments: [
+ { id: "doc-1", project_id: "proj-1", name: "report.pdf", size_bytes: 10 },
+ ],
+ });
+ await page.route("**/api/features", (route) =>
+ route.fulfill({
+ json: {
+ agents_api: { enabled: true },
+ browser_control: { enabled: false },
+ mcp_tasks: { enabled: false },
+ knowledge_base: { scope_selection_enabled: false },
+ conversation_references: { enabled: true, max_references: 3 },
+ },
+ }),
+ );
+});
+test("mid-draft skill selection preserves both text and caret and submits the activation", async ({
+ page,
+}) => {
+ await page.goto("/workspace/chats/new");
+ const input = composer(page);
+ await input.fill("Use carefully");
+ await input.press("Home");
+ for (let i = 0; i < 4; i++) await input.press("ArrowRight");
+ await input.pressSequentially("@res");
+ await expect(
+ page.getByRole("option", { name: "research Research a topic" }),
+ ).toBeVisible();
+ await input.press("Enter");
+ await expect(page.getByTestId("inline-skill-reference")).toBeVisible();
+ await expect(input).toHaveText("Use ✦research carefully");
+ await input.pressSequentially("it");
+ await expect(input).toHaveText("Use ✦research it carefully");
+ const request = nextRun(page);
+ await input.press("Enter");
+ expect(
+ JSON.stringify((await request).postDataJSON().input.messages),
+ ).toContain("Use @research it carefully");
+});
+test("keeps multiple inline skills and restores their positions on reload", async ({
+ page,
+}) => {
+ await page.goto("/workspace/chats/new");
+ const input = composer(page);
+ await input.fill("@res");
+ await page.getByRole("option", { name: "research Research a topic" }).click();
+ await input.pressSequentially("Keep this @wri");
+ await page.getByRole("option", { name: "writing Write a report" }).click();
+ await expect(input).toHaveText("✦research Keep this ✦writing ");
+ await page.reload();
+ await expect(input).toHaveText("✦research Keep this ✦writing ");
+ const request = nextRun(page);
+ await input.press("Enter");
+ const message = (await request).postDataJSON().input.messages.at(-1);
+ expect(message.additional_kwargs.skill_references).toEqual([
+ "research",
+ "writing",
+ ]);
+});
+test("emails and dismissed queries stay literal", async ({ page }) => {
+ await page.goto("/workspace/chats/new");
+ const input = composer(page);
+ await input.fill("a@example.com");
+ await expect(page.getByTestId("mention-picker")).toBeHidden();
+ await input.fill("@res");
+ await expect(page.getByTestId("mention-picker")).toBeVisible();
+ await input.press("Escape");
+ await expect(page.getByTestId("mention-picker")).toBeHidden();
+ await expect(input).toHaveValue("@res");
+});
+test("attaches a document to a new project thread and submits its confirmed file", async ({
+ page,
+}) => {
+ await page.goto("/workspace/chats/new?project=proj-1");
+ const input = composer(page);
+ await input.fill("Read @report");
+ await page.getByRole("option", { name: "report.pdf" }).click();
+ await expect(page.getByTestId("project-attachment-chip")).toContainText(
+ "report.pdf",
+ );
+ await expect(input).toHaveText("Read ▤report.pdf ");
+ await expect(page).not.toHaveURL(/\/new/);
+ await page.reload();
+ await expect(page.getByTestId("project-attachment-chip")).toContainText(
+ "report.pdf",
+ );
+ await expect(input).toHaveText("Read ▤report.pdf ");
+ const request = nextRun(page);
+ await input.press("Enter");
+ expect(
+ JSON.stringify((await request).postDataJSON().input.messages),
+ ).toContain("/mnt/user-data/uploads/report.pdf");
+});
+test("restores a conversation reference and sends its run-scoped ID", async ({
+ page,
+}) => {
+ await page.goto("/workspace/chats/new");
+ const input = composer(page);
+ await input.fill("Summarize @Writer");
+ await page.getByRole("option", { name: "Writer brief" }).click();
+ await expect(page.getByTestId("conversation-reference-chip")).toContainText(
+ "Writer brief",
+ );
+ await page.reload();
+ await expect(page.getByTestId("conversation-reference-chip")).toContainText(
+ "Writer brief",
+ );
+ const request = nextRun(page);
+ await input.press("Enter");
+ expect(
+ (await request).postDataJSON().context.conversation_references,
+ ).toEqual([MOCK_THREAD_ID]);
+});
+test("mouse upload entry and mobile picker stay usable", async ({
+ page,
+}, testInfo) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/workspace/chats/new");
+ await page.getByTestId("mention-button").click();
+ const picker = page.getByTestId("mention-picker");
+ await expect(picker).toBeVisible();
+ const box = await picker.boundingBox();
+ expect(box!.x).toBeGreaterThanOrEqual(0);
+ expect(box!.x + box!.width).toBeLessThanOrEqual(390);
+ await page.screenshot({ path: testInfo.outputPath("mentions-mobile.png") });
+ const chooser = page.waitForEvent("filechooser");
+ await page.getByRole("option", { name: "Upload a file" }).click();
+ await (
+ await chooser
+ ).setFiles({
+ name: "note.txt",
+ mimeType: "text/plain",
+ buffer: Buffer.from("hello"),
+ });
+ await expect(page.getByText("note.txt", { exact: true })).toBeVisible();
+});
+
+for (const metadataState of ["pending", "failed"] as const) {
+ test(`keeps confirmed project files after materialization when metadata is ${metadataState}`, async ({
+ page,
+ }) => {
+ let release!: () => void;
+ const pending = new Promise((resolve) => {
+ release = resolve;
+ });
+ await page.route(
+ /\/api\/(?:langgraph\/)?threads\/[^/]+$/,
+ async (route) => {
+ if (route.request().method() !== "GET") return route.fallback();
+ if (metadataState === "pending") await pending;
+ return route.fulfill({
+ status: 503,
+ json: { detail: "metadata unavailable" },
+ });
+ },
+ );
+ try {
+ await page.goto("/workspace/chats/new?project=proj-1");
+ await composer(page).fill("Read @report");
+ await page.getByRole("option", { name: "report.pdf" }).click();
+ await expect(page).not.toHaveURL(/\/new/);
+ await expect(page.getByTestId("project-attachment-chip")).toBeVisible();
+ await page.getByTestId("mention-button").click();
+ await expect(
+ page.getByRole("option", { name: "report.pdf" }),
+ ).toBeVisible();
+ await expect(
+ page.getByText("Open a project chat to reference project files."),
+ ).toBeHidden();
+ } finally {
+ release();
+ }
+ });
+}
+
+for (const enabledAfterRetry of [true, false]) {
+ test(`preserves references across a failed discovery and retries to enabled=${enabledAfterRetry}`, async ({
+ page,
+ }) => {
+ await page.goto("/workspace/chats/new");
+ const input = composer(page);
+ await input.fill("Summarize @Writer");
+ await page.getByRole("option", { name: "Writer brief" }).click();
+ await expect(page.getByTestId("conversation-reference-chip")).toBeVisible();
+ let failed = true;
+ await page.route("**/api/features", (route) =>
+ failed
+ ? route.fulfill({ status: 503, body: "Temporarily unavailable" })
+ : enabledAfterRetry
+ ? route.fallback()
+ : route.fulfill({
+ json: {
+ agents_api: { enabled: true },
+ conversation_references: { enabled: false, max_references: 0 },
+ },
+ }),
+ );
+ await page.reload();
+ const retry = page.getByTestId("retry-conversation-capability");
+ await expect(retry).toBeVisible();
+ await expect(page.getByTestId("conversation-reference-chip")).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Submit", exact: true }),
+ ).toBeDisabled();
+ await input.press("End");
+ await input.pressSequentially("after retry");
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ Object.values(sessionStorage).some(
+ (value) =>
+ value.includes("after retry") &&
+ value.includes("ref:conversation:"),
+ ),
+ ),
+ )
+ .toBe(true);
+ await page.reload();
+ await expect(retry).toBeVisible();
+ await expect(page.getByTestId("conversation-reference-chip")).toBeVisible();
+ failed = false;
+ await retry.click();
+ await expect(retry).toBeHidden();
+ await expect(
+ page.getByRole("button", { name: "Submit", exact: true }),
+ ).toBeEnabled();
+ if (enabledAfterRetry)
+ await expect(
+ page.getByTestId("conversation-reference-chip"),
+ ).toBeVisible();
+ else
+ await expect(
+ page.getByTestId("conversation-reference-chip"),
+ ).toBeHidden();
+ const request = nextRun(page);
+ await input.press("Enter");
+ const body = (await request).postDataJSON();
+ expect(body.context.conversation_references ?? []).toEqual(
+ enabledAfterRetry ? [MOCK_THREAD_ID] : [],
+ );
+ expect(JSON.stringify(body.input.messages)).toContain("after retry");
+ });
+}
+
+test("polish preserves reordered overlapping reference labels and their IDs", async ({
+ page,
+}) => {
+ await page.route("**/api/input-polish", (route) =>
+ route.fulfill({
+ json: {
+ rewritten_text: "Use @research-tools first, then @research.",
+ changed: true,
+ },
+ }),
+ );
+ await page.goto("/workspace/chats/new");
+ const input = composer(page);
+ await input.fill(
+ "Use @[research](ref:skill:research) after @[research-tools](ref:skill:research-tools)",
+ );
+ await expect(page.getByTestId("inline-skill-reference")).toHaveCount(2);
+ await page.getByTestId("polish-input-button").click();
+ await expect(input).toHaveText("Use ✦research-tools first, then ✦research.");
+ await expect(
+ page.getByTestId("inline-skill-reference").first(),
+ ).toHaveAttribute(
+ "data-reference",
+ "@[research-tools](ref:skill:research-tools)",
+ );
+ const request = nextRun(page);
+ await input.press("Enter");
+ const message = (await request).postDataJSON().input.messages.at(-1);
+ expect(message.additional_kwargs.skill_references).toEqual([
+ "research-tools",
+ "research",
+ ]);
+ expect(JSON.stringify(message)).toContain(
+ "Use @research-tools first, then @research.",
+ );
+});
diff --git a/frontend/tests/unit/components/workspace/input-box-agent-skill-hydration.dom.test.tsx b/frontend/tests/unit/components/workspace/input-box-agent-skill-hydration.dom.test.tsx
index b8f822ed4e..b3a3df25bb 100644
--- a/frontend/tests/unit/components/workspace/input-box-agent-skill-hydration.dom.test.tsx
+++ b/frontend/tests/unit/components/workspace/input-box-agent-skill-hydration.dom.test.tsx
@@ -113,9 +113,7 @@ describe("InputBox agent skill draft hydration", () => {
agentSkillNames: undefined,
});
- expect(
- screen.queryByRole("button", { name: "Remove /research" }),
- ).toBeNull();
+ expect(screen.queryByRole("button", { name: "Remove skill" })).toBeNull();
expect(window.sessionStorage.getItem(draftKey)).toContain(
'"skillName":"research"',
);
@@ -160,9 +158,7 @@ describe("InputBox agent skill draft hydration", () => {
);
await waitFor(() => {
- expect(
- screen.getByRole("button", { name: "Remove /research" }),
- ).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy();
});
expect(screen.getByRole("textbox").textContent).toBe("topic");
});
@@ -179,9 +175,7 @@ describe("InputBox agent skill draft hydration", () => {
});
await waitFor(() => {
- expect(
- screen.getByRole("button", { name: "Remove /research" }),
- ).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy();
});
});
});
diff --git a/frontend/tests/unit/components/workspace/input-box-helpers.test.ts b/frontend/tests/unit/components/workspace/input-box-helpers.test.ts
index 11caddaa42..44b749b92e 100644
--- a/frontend/tests/unit/components/workspace/input-box-helpers.test.ts
+++ b/frontend/tests/unit/components/workspace/input-box-helpers.test.ts
@@ -518,3 +518,14 @@ describe("findSuggestionTemplatePlaceholder", () => {
expect(findSuggestionTemplatePlaceholder("no placeholder here")).toBeNull();
});
});
+
+it("never offers filesystem-discovered names outside the activation grammar", () => {
+ const names = ["research", "a--b", "a_b", "Research", "a-", "context"];
+ expect(
+ getMatchingSkillSuggestions(
+ names.map((name) => makeSkill(name)),
+ "",
+ [],
+ ).map((item) => item.name),
+ ).toEqual(["research", "context"]);
+});
diff --git a/frontend/tests/unit/components/workspace/input-box-mentions.dom.test.tsx b/frontend/tests/unit/components/workspace/input-box-mentions.dom.test.tsx
new file mode 100644
index 0000000000..74a617f5b9
--- /dev/null
+++ b/frontend/tests/unit/components/workspace/input-box-mentions.dom.test.tsx
@@ -0,0 +1,742 @@
+import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import type { ComponentProps, ReactNode } from "react";
+
+import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
+import { InputBox } from "@/components/workspace/input-box";
+import { referenceToken } from "@/components/workspace/mentions/inline-references";
+import { ThreadContext } from "@/components/workspace/messages/context";
+import { AuthProvider } from "@/core/auth/AuthProvider";
+import { DEFAULT_LOCALE } from "@/core/i18n";
+import { I18nProvider } from "@/core/i18n/context";
+import {
+ buildComposerDraftKey,
+ writeComposerDraft,
+} from "@/core/threads/composer-draft";
+
+rs.mock("next/navigation", () => ({
+ useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }),
+ usePathname: () => "/workspace",
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+rs.mock("@/core/models/hooks", () => ({
+ useModels: () => ({
+ models: [],
+ tokenUsageEnabled: false,
+ isLoading: false,
+ isFetching: false,
+ error: null,
+ refetch: rs.fn(),
+ }),
+}));
+
+const skillsQuery = {
+ isLoading: false,
+ error: null as Error | null,
+ refetch: rs.fn(),
+};
+rs.mock("@/core/skills/hooks", () => ({
+ useSkills: () => ({
+ skills: [
+ {
+ name: "research",
+ description: "Research a topic",
+ category: "general",
+ license: "MIT",
+ enabled: true,
+ editable: false,
+ },
+ ],
+ ...skillsQuery,
+ }),
+}));
+
+// Each test gets its own thread id: the composer persists the selected skill in
+// a debounced, thread-scoped draft, and a shared id would let the first test's
+// selection land in the second test's storage key.
+function renderComposer(
+ threadId = "mentions-thread",
+ onSubmit = rs.fn(),
+ onPrepareThread = rs.fn(),
+ props: Partial> = {},
+ isMock = true,
+) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ const tree: ReactNode = (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ return render(tree);
+}
+
+const attach = rs.fn();
+const capability = {
+ enabled: true,
+ maxReferences: 3,
+ isLoading: false,
+ isSuccess: true,
+ error: null as Error | null,
+ refetch: rs.fn(),
+};
+const polish = rs.fn();
+rs.mock("@/core/input-polish/api", () => ({
+ polishInputDraft: (...args: unknown[]) => polish(...args),
+}));
+rs.mock("@/core/features/hooks", () => ({
+ useConversationReferencesCapability: () => capability,
+}));
+rs.mock("@/core/projects/api", () => ({
+ attachProjectDocument: (...args: unknown[]) => attach(...args),
+}));
+rs.mock("@/core/projects/hooks", () => ({
+ useInfiniteProjectDocuments: () => ({
+ data: {
+ pages: [
+ {
+ documents: [
+ {
+ id: "doc-1",
+ name: "report.pdf",
+ size_bytes: 10,
+ content_missing: false,
+ },
+ ],
+ },
+ ],
+ },
+ isPending: false,
+ }),
+}));
+rs.mock("@/core/threads/hooks", () => ({
+ useInfiniteThreads: () => ({
+ data: {
+ pages: [
+ [
+ {
+ thread_id: "source-1",
+ values: { title: "Writer brief" },
+ metadata: { agent_name: "writer" },
+ },
+ ...[2, 3, 4].map((id) => ({
+ thread_id: `source-${id}`,
+ values: { title: `Brief ${id}` },
+ metadata: {},
+ })),
+ ],
+ ],
+ },
+ isPending: false,
+ }),
+}));
+
+beforeEach(() => {
+ capability.enabled = true;
+ capability.maxReferences = 3;
+ capability.isLoading = false;
+ capability.isSuccess = true;
+ capability.error = null;
+ capability.refetch.mockReset();
+ polish.mockReset();
+ skillsQuery.isLoading = false;
+ skillsQuery.error = null;
+ skillsQuery.refetch.mockReset();
+ attach.mockReset();
+ attach.mockResolvedValue({
+ filename: "report.pdf",
+ size_bytes: 10,
+ virtual_path: "/mnt/user-data/uploads/report.pdf",
+ artifact_url: "/artifact",
+ });
+});
+afterEach(() => {
+ cleanup();
+ window.sessionStorage.clear();
+ rs.restoreAllMocks();
+});
+function enterMention(
+ container: HTMLElement,
+ text: string,
+ caret = text.length,
+) {
+ const input = container.querySelector("textarea");
+ if (!input) {
+ const editor = container.querySelector(
+ '[contenteditable="true"]',
+ )!;
+ const node = document.createTextNode(" " + text);
+ editor.append(node);
+ const range = document.createRange();
+ range.setStart(node, 1 + caret);
+ range.collapse(true);
+ window.getSelection()?.removeAllRanges();
+ window.getSelection()?.addRange(range);
+ fireEvent.input(editor);
+ return editor as HTMLTextAreaElement;
+ }
+ fireEvent.focus(input);
+ fireEvent.change(input, {
+ target: { value: text, selectionStart: caret, selectionEnd: caret },
+ });
+ return input;
+}
+
+describe("unified composer mentions", () => {
+ it("keeps a skill inline in the middle and sends its explicit activation metadata", async () => {
+ const submit = rs.fn();
+ const { container } = renderComposer("skill-mention", submit);
+ const input = enterMention(container, "Use @res carefully", 8);
+ fireEvent.keyDown(input, { key: "Enter" });
+ await waitFor(() =>
+ expect(screen.getByTestId("inline-skill-reference")).toBeTruthy(),
+ );
+ const editor = container.querySelector('[contenteditable="true"]')!;
+ await waitFor(() =>
+ expect(editor.textContent).toBe("Use ✦research carefully"),
+ );
+ fireEvent.keyDown(editor, { key: "Enter" });
+ await waitFor(() => expect(submit).toHaveBeenCalled());
+ expect(submit.mock.calls[0]![0].text).toBe("Use @research carefully");
+ expect(submit.mock.calls[0]![1].additionalKwargs.skill_references).toEqual([
+ "research",
+ ]);
+ });
+ it("leaves emails and cancelled or composing queries as text", () => {
+ const { container } = renderComposer();
+ enterMention(container, "a@example.com");
+ expect(screen.queryByTestId("mention-picker")).toBeNull();
+ const input = enterMention(container, "@res");
+ fireEvent.keyDown(input, { key: "Enter", keyCode: 229 });
+ expect(input.value).toBe("@res");
+ expect(screen.queryByRole("button", { name: "Remove skill" })).toBeNull();
+ fireEvent.keyDown(input, { key: "Escape" });
+ expect(screen.queryByTestId("mention-picker")).toBeNull();
+ expect(input.value).toBe("@res");
+ });
+ it("prepares the project thread, attaches once, and sends the confirmed file", async () => {
+ const submit = rs.fn();
+ const prepare = rs.fn();
+ const { container } = renderComposer("file-mention", submit, prepare);
+ enterMention(container, "Read @report");
+ fireEvent.click(screen.getByRole("option", { name: "report.pdf" }));
+ await waitFor(() =>
+ expect(screen.getByTestId("project-attachment-chip")).toBeTruthy(),
+ );
+ expect(prepare).toHaveBeenCalledTimes(1);
+ expect(attach).toHaveBeenCalledWith("project-1", "doc-1", "file-mention");
+ enterMention(container, "Read @report");
+ fireEvent.click(screen.getByRole("option", { name: "report.pdf" }));
+ expect(attach).toHaveBeenCalledTimes(1);
+ fireEvent.submit(container.querySelector("form")!);
+ await waitFor(() => expect(submit).toHaveBeenCalled());
+ expect(submit.mock.calls[0]![1].additionalKwargs.files).toEqual([
+ {
+ filename: "report.pdf",
+ size: 10,
+ path: "/mnt/user-data/uploads/report.pdf",
+ status: "uploaded",
+ },
+ ]);
+ });
+ it("keeps the query after an attachment error and permits retry", async () => {
+ attach.mockRejectedValueOnce(new Error("offline"));
+ const { container } = renderComposer();
+ const input = enterMention(container, "Read @report");
+ fireEvent.click(screen.getByRole("option", { name: "report.pdf" }));
+ await waitFor(() =>
+ expect(screen.getByRole("alert").textContent).toContain("Could not add"),
+ );
+ expect(input.value).toBe("Read @report");
+ expect(screen.queryByTestId("project-attachment-chip")).toBeNull();
+ fireEvent.click(screen.getByRole("option", { name: "report.pdf" }));
+ await waitFor(() =>
+ expect(screen.getByTestId("project-attachment-chip")).toBeTruthy(),
+ );
+ });
+ it("persists a conversation reference with its draft and sends its ID and display metadata", async () => {
+ const submit = rs.fn();
+ const rendered = renderComposer("reference-draft", submit);
+ enterMention(rendered.container, "Review @Writer");
+ fireEvent.click(screen.getByRole("option", { name: "Writer brief" }));
+ await waitFor(() =>
+ expect(screen.getByTestId("conversation-reference-chip")).toBeTruthy(),
+ );
+ fireEvent(window, new Event("pagehide"));
+ rendered.unmount();
+ const { container } = renderComposer("reference-draft", submit);
+ await waitFor(() =>
+ expect(screen.getByTestId("conversation-reference-chip")).toBeTruthy(),
+ );
+ fireEvent.submit(container.querySelector("form")!);
+ await waitFor(() => expect(submit).toHaveBeenCalled());
+ expect(submit.mock.calls[0]![1].conversationReferences).toEqual([
+ "source-1",
+ ]);
+ expect(
+ submit.mock.calls[0]![1].additionalKwargs.conversation_references,
+ ).toEqual([
+ { thread_id: "source-1", title: "Writer brief", agent_name: "writer" },
+ ]);
+ });
+ it("hides conversations when the deployment disables references", () => {
+ capability.enabled = false;
+ const { container } = renderComposer();
+ enterMention(container, "@");
+ expect(screen.queryByRole("option", { name: "Writer brief" })).toBeNull();
+ });
+ it("the attachment button opens the same picker", () => {
+ renderComposer();
+ fireEvent.click(screen.getByTestId("add-attachments-button"));
+ expect(screen.getByTestId("mention-picker")).toBeTruthy();
+ expect(screen.getByRole("option", { name: "Upload a file" })).toBeTruthy();
+ });
+ it("does not select upload automatically for an unknown query", () => {
+ const { container } = renderComposer();
+ enterMention(container, "@missing");
+ const upload = screen.getByRole("option", { name: "Upload a file" });
+ expect(upload.getAttribute("aria-selected")).toBe("false");
+ });
+ it("ignores late attachment completion after the composer is replaced", async () => {
+ let finish!: (value: unknown) => void;
+ attach.mockReturnValueOnce(
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ const first = renderComposer("old-thread");
+ enterMention(first.container, "Read @report");
+ fireEvent.click(screen.getByRole("option", { name: "report.pdf" }));
+ await waitFor(() => expect(attach).toHaveBeenCalled());
+ first.unmount();
+ const second = renderComposer("new-thread");
+ enterMention(second.container, "Keep this draft");
+ finish({
+ filename: "report.pdf",
+ size_bytes: 10,
+ virtual_path: "/mnt/user-data/uploads/report.pdf",
+ artifact_url: "/artifact",
+ });
+ await waitFor(() =>
+ expect(second.container.querySelector("textarea")!.value).toBe(
+ "Keep this draft",
+ ),
+ );
+ expect(screen.queryByTestId("project-attachment-chip")).toBeNull();
+ });
+
+ it("enforces the conversation cap while allowing removal and reselection", () => {
+ const { container } = renderComposer();
+ for (const name of ["Writer brief", "Brief 2", "Brief 3"]) {
+ enterMention(container, `Review @${name.split(" ")[0]}`);
+ fireEvent.click(screen.getByRole("option", { name }));
+ }
+ enterMention(container, "Review @Brief");
+ const fourth = screen.getByRole("option", { name: "Brief 4" });
+ expect(fourth.hasAttribute("disabled")).toBe(true);
+ screen.getAllByTestId("conversation-reference-chip")[0]!.remove();
+ fireEvent.input(container.querySelector('[contenteditable="true"]')!);
+ expect(
+ screen.getByRole("option", { name: "Brief 4" }).hasAttribute("disabled"),
+ ).toBe(false);
+ });
+});
+
+function saveDraft(threadId: string, text: string, extra = {}) {
+ writeComposerDraft(
+ window.sessionStorage,
+ buildComposerDraftKey({
+ userId: "user-1",
+ agentName: null,
+ threadId,
+ }),
+ { text, skillName: null, ...extra },
+ );
+}
+
+describe("reference review regressions", () => {
+ for (const entry of ["paste", "draft"] as const) {
+ it(`resolves conversation tokens from ${entry} without another editor input`, async () => {
+ const text =
+ referenceToken("conversation", "source-1", "Writer brief") +
+ " summarize";
+ if (entry === "draft") saveDraft("raw-token", text);
+ const submit = rs.fn();
+ const { container } = renderComposer("raw-token", submit);
+ if (entry === "paste") enterMention(container, text);
+ await waitFor(() =>
+ expect(screen.getByTestId("conversation-reference-chip")).toBeTruthy(),
+ );
+ fireEvent.submit(container.querySelector("form")!);
+ await waitFor(() => expect(submit).toHaveBeenCalledTimes(1));
+ expect(submit.mock.calls[0]![1].conversationReferences).toEqual([
+ "source-1",
+ ]);
+ expect(
+ submit.mock.calls[0]![1].additionalKwargs.conversation_references,
+ ).toEqual([{ thread_id: "source-1", title: "Writer brief" }]);
+ });
+ }
+ it("drops disabled restored context and renders its label as ordinary text", async () => {
+ capability.enabled = false;
+ saveDraft(
+ "disabled-restore",
+ referenceToken("conversation", "source-1", "Writer brief") + " summarize",
+ {
+ conversationReferences: [
+ { threadId: "source-1", title: "Writer brief" },
+ ],
+ },
+ );
+ const submit = rs.fn();
+ const { container } = renderComposer("disabled-restore", submit);
+ await waitFor(() =>
+ expect(screen.queryByTestId("conversation-reference-chip")).toBeNull(),
+ );
+ fireEvent.submit(container.querySelector("form")!);
+ await waitFor(() => expect(submit).toHaveBeenCalled());
+ expect(submit.mock.calls[0]![1].conversationReferences).toBeUndefined();
+ expect(submit.mock.calls[0]![0].text).toContain("@Writer brief");
+ });
+ it("uses the current capability limit on paste, edits, and picker counts", async () => {
+ capability.maxReferences = 4;
+ const submit = rs.fn();
+ const { container } = renderComposer("cap-four", submit);
+ enterMention(
+ container,
+ [1, 2, 3, 4, 5]
+ .map((id) =>
+ referenceToken("conversation", `source-${id}`, `Brief ${id}`),
+ )
+ .join(" "),
+ );
+ await waitFor(() =>
+ expect(screen.getAllByTestId("conversation-reference-chip")).toHaveLength(
+ 4,
+ ),
+ );
+ const editor = container.querySelector('[contenteditable="true"]')!;
+ fireEvent.input(editor);
+ fireEvent.submit(container.querySelector("form")!);
+ await waitFor(() => expect(submit).toHaveBeenCalled());
+ expect(submit.mock.calls[0]![1].conversationReferences).toEqual([
+ "source-1",
+ "source-2",
+ "source-3",
+ "source-4",
+ ]);
+ });
+ it("does not send while conversation capability is loading", async () => {
+ capability.isLoading = true;
+ capability.isSuccess = false;
+ const submit = rs.fn();
+ const { container } = renderComposer("cap-loading", submit);
+ enterMention(
+ container,
+ referenceToken("conversation", "source-1", "Brief"),
+ );
+ fireEvent.submit(container.querySelector("form")!);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(submit).not.toHaveBeenCalled();
+ });
+ it("rejects pasted skill batches over the cap without losing the draft", async () => {
+ const submit = rs.fn();
+ const { container } = renderComposer("skill-cap", submit);
+ enterMention(
+ container,
+ Array.from({ length: 17 }, (_, i) =>
+ referenceToken("skill", `skill-${i}`, `skill-${i}`),
+ ).join(" "),
+ );
+ fireEvent.submit(container.querySelector("form")!);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(submit).not.toHaveBeenCalled();
+ expect(screen.getAllByTestId("inline-skill-reference")).toHaveLength(17);
+ });
+ it("toggles an already selected skill instead of inserting duplicate tokens", async () => {
+ const { container } = renderComposer("toggle-skill");
+ enterMention(container, "@res");
+ fireEvent.click(
+ screen.getByRole("option", { name: "research Research a topic" }),
+ );
+ enterMention(container, "@res");
+ const option = screen.getByRole("option", {
+ name: "research Research a topic",
+ });
+ expect(option.getAttribute("aria-selected")).toBe("true");
+ fireEvent.click(option);
+ await waitFor(() =>
+ expect(screen.queryByTestId("inline-skill-reference")).toBeNull(),
+ );
+ });
+ it("reports skills loading and retries a failed skills query", () => {
+ skillsQuery.error = new Error("offline");
+ renderComposer("skill-error");
+ fireEvent.click(screen.getByTestId("mention-button"));
+ expect(screen.getByRole("alert").textContent).toContain("Could not load");
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(skillsQuery.refetch).toHaveBeenCalledTimes(1);
+ });
+ it("exposes the keyboard highlight separately from actual selections", () => {
+ const { container } = renderComposer("aria");
+ const input = enterMention(container, "@res");
+ const option = screen.getByRole("option", {
+ name: "research Research a topic",
+ });
+ expect(option.getAttribute("aria-selected")).toBe("false");
+ expect(option.id).not.toBe("");
+ expect(input.getAttribute("aria-activedescendant")).toBe(option.id);
+ });
+ it("locks legacy skill removal until attachment migration settles", async () => {
+ let finish!: (value: unknown) => void;
+ attach.mockReturnValueOnce(
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ saveDraft("new", "Read @report", { skillName: "research" });
+ const materialized = rs.fn();
+ const { container } = renderComposer("materialized", rs.fn(), rs.fn(), {
+ draftThreadId: "new",
+ onReferenceFileAttached: materialized,
+ });
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy(),
+ );
+ fireEvent.click(screen.getByTestId("mention-button"));
+ fireEvent.click(screen.getByRole("option", { name: "report.pdf" }));
+ await waitFor(() => expect(attach).toHaveBeenCalled());
+ const remove = screen.getByRole("button", { name: "Remove skill" });
+ expect(remove.hasAttribute("disabled")).toBe(true);
+ fireEvent.click(remove);
+ finish({
+ filename: "report.pdf",
+ size_bytes: 10,
+ virtual_path: "/mnt/user-data/uploads/report.pdf",
+ artifact_url: "/artifact",
+ });
+ await waitFor(() => expect(materialized).toHaveBeenCalledTimes(1));
+ expect(container.textContent).toContain("@research");
+ });
+});
+
+it("announces a pending skills request in the picker", () => {
+ skillsQuery.isLoading = true;
+ renderComposer("skills-pending");
+ fireEvent.click(screen.getByTestId("mention-button"));
+ expect(screen.getByRole("listbox").getAttribute("aria-busy")).toBe("true");
+ expect(screen.getByRole("status").textContent).toContain("Loading");
+});
+
+it("the picker search exposes its active option while selected state remains independent", () => {
+ renderComposer("search-aria");
+ fireEvent.click(screen.getByTestId("mention-button"));
+ const search = screen.getByRole("combobox");
+ const research = screen.getByRole("option", {
+ name: "research Research a topic",
+ });
+ expect(search.getAttribute("aria-activedescendant")).toBe(research.id);
+ expect(research.getAttribute("aria-selected")).toBe("false");
+ fireEvent.keyDown(search, { key: "ArrowDown" });
+ expect(search.getAttribute("aria-activedescendant")).toBe(
+ screen.getByRole("option", { name: "report.pdf" }).id,
+ );
+ expect(research.getAttribute("aria-selected")).toBe("false");
+});
+
+it("pasted file labels never manufacture confirmed attachment context", async () => {
+ const submit = rs.fn();
+ const { container } = renderComposer("file-paste", submit);
+ enterMention(
+ container,
+ referenceToken("file", "doc-1", "report.pdf") + " read",
+ );
+ fireEvent.submit(container.querySelector("form")!);
+ await waitFor(() => expect(submit).toHaveBeenCalled());
+ expect(submit.mock.calls[0]![1].additionalKwargs?.files).toBeUndefined();
+ expect(attach).not.toHaveBeenCalled();
+});
+
+it("a capability rerender before send acceptance cannot revive the accepted draft", async () => {
+ let accepted!: () => void;
+ let release!: () => void;
+ const pending = new Promise((resolve) => {
+ release = resolve;
+ });
+ const submit = rs.fn((_message, options) => {
+ accepted = options.onSent;
+ return pending;
+ });
+ const { container } = renderComposer("accepted-rerender", submit);
+ enterMention(container, "Accepted task");
+ fireEvent.submit(container.querySelector("form")!);
+ await waitFor(() => expect(submit).toHaveBeenCalledTimes(1));
+ // Opening the picker changes state without editing the submitted snapshot.
+ fireEvent.click(screen.getByTestId("mention-button"));
+ await waitFor(() =>
+ expect(screen.getByTestId("mention-picker")).toBeTruthy(),
+ );
+ accepted();
+ fireEvent(window, new Event("pagehide"));
+ expect(Object.values(window.sessionStorage).join("\n")).not.toContain(
+ "Accepted task",
+ );
+ release();
+});
+
+describe("reference discovery failures and polish round trips", () => {
+ it("preserves restored references and blocks sending until capability retry succeeds", async () => {
+ capability.enabled = false;
+ capability.maxReferences = 0;
+ capability.isSuccess = false;
+ capability.error = new Error("503 temporary");
+ const token = referenceToken("conversation", "source-1", "Writer brief");
+ const text = token + " summarize";
+ saveDraft("cap-error", text, {
+ conversationReferences: [
+ { threadId: "source-1", title: "Writer brief", agentName: "writer" },
+ ],
+ });
+ const submit = rs.fn();
+ const { container } = renderComposer("cap-error", submit);
+ await waitFor(() =>
+ expect(screen.getByTestId("conversation-reference-chip")).toBeTruthy(),
+ );
+ fireEvent.submit(container.querySelector("form")!);
+ await new Promise((resolve) => setTimeout(resolve, 400));
+ expect(submit).not.toHaveBeenCalled();
+ const key = buildComposerDraftKey({
+ userId: "user-1",
+ agentName: null,
+ threadId: "cap-error",
+ });
+ expect(JSON.parse(window.sessionStorage.getItem(key)!).text).toBe(text);
+ expect(
+ JSON.parse(window.sessionStorage.getItem(key)!).conversationReferences,
+ ).toEqual([
+ { threadId: "source-1", title: "Writer brief", agentName: "writer" },
+ ]);
+ capability.refetch.mockImplementation(() => {
+ capability.isSuccess = true;
+ capability.enabled = true;
+ capability.maxReferences = 3;
+ capability.error = null;
+ });
+ fireEvent.click(screen.getByTestId("retry-conversation-capability"));
+ expect(capability.refetch).toHaveBeenCalledTimes(1);
+ // An ordinary editor update observes the recovered mock capability.
+ enterMention(container, " now");
+ fireEvent.submit(container.querySelector("form")!);
+ await waitFor(() => expect(submit).toHaveBeenCalledTimes(1));
+ expect(submit.mock.calls[0]![1].conversationReferences).toEqual([
+ "source-1",
+ ]);
+ expect(
+ submit.mock.calls[0]![1].additionalKwargs.conversation_references[0]
+ .agent_name,
+ ).toBe("writer");
+ });
+
+ it("offers capability retry from the picker without a selected conversation", async () => {
+ capability.enabled = false;
+ capability.isSuccess = false;
+ capability.error = new Error("503 temporary");
+ renderComposer("picker-cap-error");
+ fireEvent.click(screen.getByTestId("mention-button"));
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(capability.refetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("restores reordered overlapping skill labels in the intended positions and supports undo", async () => {
+ const short = referenceToken("skill", "research", "research");
+ const long = referenceToken("skill", "research-tools", "research-tools");
+ const original = `Use ${short} after ${long}`;
+ saveDraft("polish-prefix", original);
+ polish.mockResolvedValue({
+ rewritten_text: "Use @research-tools first, then @research.",
+ changed: true,
+ });
+ const submit = rs.fn();
+ const { container } = renderComposer(
+ "polish-prefix",
+ submit,
+ rs.fn(),
+ {},
+ false,
+ );
+ await waitFor(() =>
+ expect(screen.getAllByTestId("inline-skill-reference")).toHaveLength(2),
+ );
+ fireEvent.click(screen.getByTestId("polish-input-button"));
+ await waitFor(() =>
+ expect(
+ container.querySelector('[contenteditable="true"]')?.textContent,
+ ).toBe("Use ✦research-tools first, then ✦research."),
+ );
+ expect(
+ screen
+ .getAllByTestId("inline-skill-reference")
+ .map((el) => el.dataset.reference),
+ ).toEqual([long, short]);
+ fireEvent.click(screen.getByTestId("polish-input-button"));
+ await waitFor(() =>
+ expect(
+ screen
+ .getAllByTestId("inline-skill-reference")
+ .map((el) => el.dataset.reference),
+ ).toEqual([short, long]),
+ );
+ fireEvent.click(screen.getByTestId("polish-input-button"));
+ await waitFor(() =>
+ expect(
+ screen
+ .getAllByTestId("inline-skill-reference")
+ .map((el) => el.dataset.reference),
+ ).toEqual([long, short]),
+ );
+ fireEvent.submit(container.querySelector("form")!);
+ await waitFor(() => expect(submit).toHaveBeenCalledTimes(1));
+ expect(submit.mock.calls[0]![0].text).toBe(
+ "Use @research-tools first, then @research.",
+ );
+ expect(submit.mock.calls[0]![1].additionalKwargs.skill_references).toEqual([
+ "research-tools",
+ "research",
+ ]);
+ });
+});
diff --git a/frontend/tests/unit/components/workspace/input-box-skill-suggestion-ime.dom.test.tsx b/frontend/tests/unit/components/workspace/input-box-skill-suggestion-ime.dom.test.tsx
index d83f07cef5..980eed4b16 100644
--- a/frontend/tests/unit/components/workspace/input-box-skill-suggestion-ime.dom.test.tsx
+++ b/frontend/tests/unit/components/workspace/input-box-skill-suggestion-ime.dom.test.tsx
@@ -109,9 +109,7 @@ describe("InputBox skill suggestion IME handling", () => {
// IME composition; the catalog must not claim it.
fireEvent.keyDown(textarea, { key: "Enter", keyCode: 229 });
- expect(
- screen.queryByRole("button", { name: "Remove /research" }),
- ).toBeNull();
+ expect(screen.queryByRole("button", { name: "Remove skill" })).toBeNull();
expect(textarea.value).toBe("/res");
});
@@ -124,9 +122,7 @@ describe("InputBox skill suggestion IME handling", () => {
fireEvent.compositionEnd(textarea);
fireEvent.keyDown(textarea, { key: "Enter", keyCode: 13 });
- expect(
- screen.queryByRole("button", { name: "Remove /research" }),
- ).toBeNull();
+ expect(screen.queryByRole("button", { name: "Remove skill" })).toBeNull();
expect(textarea.value).toBe("/res");
});
@@ -141,9 +137,7 @@ describe("InputBox skill suggestion IME handling", () => {
fireEvent.keyDown(textarea, { key: "Enter", keyCode: 13 });
- expect(
- screen.getByRole("button", { name: "Remove /research" }),
- ).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy();
});
it("still selects the highlighted skill on a plain Enter", () => {
@@ -152,8 +146,6 @@ describe("InputBox skill suggestion IME handling", () => {
fireEvent.keyDown(textarea, { key: "Enter" });
- expect(
- screen.getByRole("button", { name: "Remove /research" }),
- ).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy();
});
});
diff --git a/frontend/tests/unit/components/workspace/mentions/inline-references.test.ts b/frontend/tests/unit/components/workspace/mentions/inline-references.test.ts
new file mode 100644
index 0000000000..65a917eeb7
--- /dev/null
+++ b/frontend/tests/unit/components/workspace/mentions/inline-references.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, it } from "@rstest/core";
+
+import {
+ inlineReferences,
+ reconcileConversationReferences,
+ referenceToken,
+ restoreReferenceLabels,
+} from "@/components/workspace/mentions/inline-references";
+
+const text = ["one", "two", "one", "self", "three"]
+ .map((id) => referenceToken("conversation", id, `Title ${id}`))
+ .join(" ");
+describe("conversation reference reconciliation", () => {
+ it("uses token order, deduplicates, excludes self, and preserves known display metadata", () => {
+ const result = reconcileConversationReferences(
+ text,
+ [{ threadId: "one", title: "Known", agentName: "writer" }],
+ { enabled: true, maxReferences: 2, isLoading: false, isSuccess: true },
+ "self",
+ );
+ expect(result.references).toEqual([
+ { threadId: "one", title: "Known", agentName: "writer" },
+ { threadId: "two", title: "Title two" },
+ ]);
+ expect(inlineReferences(result.text).map((ref) => ref.id)).toEqual([
+ "one",
+ "two",
+ "one",
+ ]);
+ expect(result.text).toContain("@Title self @Title three");
+ });
+ it("retains pending text, then flattens disabled references without losing user words", () => {
+ expect(
+ reconcileConversationReferences(
+ text,
+ [],
+ { enabled: false, maxReferences: 0, isLoading: true, isSuccess: false },
+ "self",
+ ),
+ ).toEqual({ text, references: [] });
+ const result = reconcileConversationReferences(
+ text,
+ [],
+ { enabled: false, maxReferences: 3, isLoading: false, isSuccess: true },
+ "self",
+ );
+ expect(result.references).toEqual([]);
+ expect(result.text).toBe(
+ "@Title one @Title two @Title one @Title self @Title three",
+ );
+ });
+});
+
+describe("polished reference labels", () => {
+ for (const [kind, shortLabel, longLabel] of [
+ ["skill", "research", "research-tools"],
+ ["file", "report", "report.pdf"],
+ ["conversation", "调研", "调研计划(Q4)"],
+ ["file", "notes[1]", "notes[1].txt"],
+ ] as const) {
+ it(`restores reordered overlapping ${kind} labels: ${longLabel}`, () => {
+ const short = referenceToken(kind, "short-id", shortLabel);
+ const long = referenceToken(kind, "long-id", longLabel);
+ expect(
+ restoreReferenceLabels(
+ `Use ${short} after ${long}`,
+ `Use @${longLabel} first, then @${shortLabel}.`,
+ ),
+ ).toBe(`Use ${long} first, then ${short}.`);
+ });
+ }
+ it("does not consume a longer unselected word", () => {
+ const token = referenceToken("skill", "research", "research");
+ expect(
+ restoreReferenceLabels(
+ token,
+ "Keep @research-tools unchanged; use @research.",
+ ),
+ ).toBe(`Keep @research-tools unchanged; use ${token}.`);
+ });
+ it("replaces each occurrence once without scanning inserted tokens", () => {
+ const token = referenceToken("skill", "id", "research");
+ expect(
+ restoreReferenceLabels(
+ `${token} ${token}`,
+ "@research then @research and @research",
+ ),
+ ).toBe(`${token} then ${token} and @research`);
+ });
+ it("retains omitted selections in their original order", () => {
+ const a = referenceToken("skill", "a", "A");
+ const b = referenceToken("file", "b", "B");
+ expect(
+ restoreReferenceLabels(`${a} then ${b}`, "Task without labels"),
+ ).toBe(`${a} ${b} Task without labels`);
+ });
+});
+
+it("preserves known reference metadata while discovery has failed, including edits", () => {
+ const kept = { threadId: "one", title: "Known", agentName: "writer" };
+ const result = reconcileConversationReferences(
+ referenceToken("conversation", "one", "Title one") + " edited",
+ [kept, { threadId: "deleted", title: "Deleted" }],
+ { enabled: false, maxReferences: 0, isLoading: false, isSuccess: false },
+ "self",
+ );
+ expect(result.references).toEqual([kept]);
+ expect(inlineReferences(result.text).map((ref) => ref.id)).toEqual(["one"]);
+});
+
+it("restores labels adjacent to Chinese prose", () => {
+ const token = referenceToken("skill", "research", "research");
+ expect(
+ restoreReferenceLabels(
+ `请用${token},总结资料。`,
+ "请用@research,总结资料。",
+ ),
+ ).toBe(`请用${token},总结资料。`);
+});
diff --git a/frontend/tests/unit/components/workspace/mentions/query.test.ts b/frontend/tests/unit/components/workspace/mentions/query.test.ts
new file mode 100644
index 0000000000..87d3b29b53
--- /dev/null
+++ b/frontend/tests/unit/components/workspace/mentions/query.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "@rstest/core";
+
+import { getMentionQuery } from "@/components/workspace/mentions/query";
+
+describe("composer mention query", () => {
+ it("replaces only the query at the caret, keeping both sides of the draft", () => {
+ const text = "请使用 @research 分析结果";
+ const caret = text.indexOf(" 分析");
+ const query = getMentionQuery(text, caret)!;
+ expect(query.query).toBe("research");
+ });
+ it("allows Unicode and filenames", () => {
+ expect(getMentionQuery("@报告.pdf", 7)?.query).toBe("报告.pdf");
+ expect(getMentionQuery("(@报告", 4)?.query).toBe("报告");
+ });
+ it("does not activate in an email, URL, or completed text", () => {
+ for (const text of [
+ "a@example.com",
+ "https://host/@name",
+ "@name ",
+ "@@name",
+ "path/@name",
+ ]) {
+ expect(getMentionQuery(text, text.length)).toBeNull();
+ }
+ });
+ it("keeps an unselected query as literal text", () => {
+ const text = "@unknown";
+ expect(getMentionQuery(text, text.length)).toEqual({
+ start: 0,
+ end: 8,
+ query: "unknown",
+ });
+ expect(text).toBe("@unknown");
+ });
+});
diff --git a/frontend/tests/unit/core/features/hooks.dom.test.tsx b/frontend/tests/unit/core/features/hooks.dom.test.tsx
new file mode 100644
index 0000000000..28031ed6b1
--- /dev/null
+++ b/frontend/tests/unit/core/features/hooks.dom.test.tsx
@@ -0,0 +1,69 @@
+import { afterEach, describe, expect, it, rs } from "@rstest/core";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { cleanup, renderHook, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+
+import { fetchConversationReferencesCapability } from "@/core/features/api";
+import { useConversationReferencesCapability } from "@/core/features/hooks";
+
+rs.mock("@/core/features/api", () => ({
+ fetchConversationReferencesCapability: rs.fn(),
+}));
+const fetchCapability = rs.mocked(fetchConversationReferencesCapability);
+function renderCapability() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return renderHook(useConversationReferencesCapability, {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ });
+}
+afterEach(() => {
+ cleanup();
+ fetchCapability.mockReset();
+});
+
+describe("conversation reference discovery", () => {
+ it("distinguishes an initial failure from a confirmed disable and supports retry", async () => {
+ fetchCapability.mockRejectedValueOnce(new Error("503 temporary"));
+ const { result } = renderCapability();
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.isSuccess).toBe(false);
+ await waitFor(() => expect(result.current.error).toBeTruthy());
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.isSuccess).toBe(false);
+ fetchCapability.mockResolvedValueOnce({ enabled: true, maxReferences: 4 });
+ await result.current.refetch();
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current).toMatchObject({
+ enabled: true,
+ maxReferences: 4,
+ error: null,
+ });
+ });
+ it("marks a successful disabled response as authoritative", async () => {
+ fetchCapability.mockResolvedValueOnce({ enabled: false, maxReferences: 0 });
+ const { result } = renderCapability();
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current).toMatchObject({
+ enabled: false,
+ isLoading: false,
+ error: null,
+ });
+ });
+ it("does not expose a cached enabled capability after a failed refresh", async () => {
+ fetchCapability.mockResolvedValueOnce({ enabled: true, maxReferences: 3 });
+ const { result } = renderCapability();
+ await waitFor(() => expect(result.current.enabled).toBe(true));
+ fetchCapability.mockRejectedValueOnce(new Error("network unavailable"));
+ await result.current.refetch();
+ await waitFor(() => expect(result.current.error).toBeTruthy());
+ expect(result.current).toMatchObject({
+ enabled: false,
+ isSuccess: false,
+ isLoading: false,
+ });
+ });
+});