mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 05:06:59 +00:00
feat(frontend): support extension providers in composer mentions (#6189)
* feat(frontend): support extension providers in composer mentions * fix(mentions): retain settled plugin suggestions during search * fix(extensions): validate identifiers and skip empty mention searches * test: stabilize history pagination after latest page refresh --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
aeeee2994f
commit
59b051b0fd
@ -383,3 +383,59 @@ To exercise the actual Turbopack development build, start the frontend with
|
||||
`DEER_FLOW_DEV_BUNDLER=turbo pnpm dev`, then run
|
||||
`PLAYWRIGHT_SKIP_WEB_SERVER=1 pnpm exec playwright test tests/e2e/bookmark-plugin.spec.ts`.
|
||||
Set `PLAYWRIGHT_BASE_URL` if the development server uses a port other than 3000.
|
||||
|
||||
### Composer mention providers
|
||||
|
||||
An optional `mentionProviders` array on a browser module contributes candidates to
|
||||
both the native `@` menu and the mobile mention picker. No custom editor is needed:
|
||||
|
||||
```javascript
|
||||
export default {
|
||||
apiVersion: 1,
|
||||
module: "example-team",
|
||||
mentionProviders: [{
|
||||
id: "members",
|
||||
label: "Team members",
|
||||
async search(query, context) {
|
||||
return context.callBackend("find_members", { query });
|
||||
},
|
||||
}],
|
||||
};
|
||||
```
|
||||
|
||||
Declare `find_members` in the package's backend actions. Its response is an array
|
||||
of `{ id, label, description? }`; the host supplies the namespace and provider ID.
|
||||
The context includes `locale`, `settings`, `threadId`, an `AbortSignal`, and the
|
||||
same viewer-bound `callBackend` service used by page surfaces. Observe the signal
|
||||
when doing asynchronous work. A provider must return a promise; the host debounces
|
||||
queries by 150 ms, truncates queries to 256 characters before calling `search`,
|
||||
stops waiting after three seconds, and discards late responses after query,
|
||||
thread, viewer, locale, or installed snapshot changes. Settled candidates remain
|
||||
visible while a new query is in flight within the same viewer/thread/locale and
|
||||
installed snapshot; changing any of those clears them immediately. A failed
|
||||
provider does not remove other providers or built-in candidates. Retrying is
|
||||
available in the picker.
|
||||
Cancellation ends host waiting; trusted JavaScript cannot be forcibly terminated.
|
||||
|
||||
Each module may register eight providers, with unique lowercase slug IDs and
|
||||
labels of at most 120 characters. The picker searches the first 16 providers in
|
||||
installation order and considers at most 50 results per provider. Item IDs are
|
||||
nonempty strings of at most 512 characters, labels at most 120, descriptions at
|
||||
most 240. Duplicate item IDs within a provider are ignored. IDs are namespaced,
|
||||
so two plugins can use the same local identifier.
|
||||
|
||||
Selections remain in the thread's existing draft as inline reference tokens.
|
||||
Submitting renders each token as `@label` in the human message and attaches:
|
||||
|
||||
```json
|
||||
{"extension_mentions":[{"namespace":"example.team","provider":"members","id":"alice","label":"Alice"}]}
|
||||
```
|
||||
|
||||
This object is in the human message's `additional_kwargs`. Up to 16 distinct
|
||||
plugin references may be submitted. Removing a token removes its metadata;
|
||||
duplicate tokens share one metadata entry. Restored references retain their
|
||||
original identities even if a package later becomes unavailable. **All metadata
|
||||
is user input, not authorization**: plugins must revalidate the referenced object,
|
||||
current installation, and viewer permissions before acting. The host does not
|
||||
interpret a reference as delegation or start another Agent. Those behaviors
|
||||
belong to the plugin's backend handlers or middleware.
|
||||
|
||||
@ -217,3 +217,16 @@ reset on conversation changes. Gateway supplies defaults for clients without a
|
||||
selector; frontend visibility must not become a runtime enforcement boundary.
|
||||
|
||||
MCP hooks and the editor default to personal scope. Mutation-option builders require an explicit scope. MCP control accessible names include the localized ownership section, so same-named shared and personal connections remain distinct without region context. Query keys include scope and authenticated user ID; remount each editor when that ID changes. Platform provided uses the deployment editor only for administrators outside static mode, restoring add, edit, toggle and delete through the existing admin-only MCP API. Ordinary users see shared entries read-only. My plugins contains personal configuration templates, Lark account authorization and the personal MCP editor; a deployment installation must not mark a personal template configured.
|
||||
|
||||
Plugin mention providers extend the existing composer picker via
|
||||
`core/extensions/use-mentions.ts`. Keep provider queries bounded and fenced by
|
||||
viewer, thread, query, and installed snapshot.
|
||||
Provider and page-surface IDs must be strings before slug validation; JavaScript
|
||||
coercion is not validation. A settled snapshot with no enabled, viewer-visible
|
||||
mention providers returns an empty result immediately without a search timer.
|
||||
Settled results may remain visible during query/retry refreshes only within the
|
||||
same viewer, thread, locale and installed snapshot; stale responses stay fenced.
|
||||
Inline plugin references serialize into human-message
|
||||
`additional_kwargs.extension_mentions`; these IDs and labels
|
||||
are untrusted input, never a routing or permission grant. Built-in mentions must
|
||||
remain usable when a provider fails or times out.
|
||||
|
||||
@ -78,6 +78,10 @@ import {
|
||||
buildConversationReferenceMetadata,
|
||||
type ConversationReference,
|
||||
} from "@/core/conversation-references";
|
||||
import {
|
||||
extensionMentionId,
|
||||
MAX_EXTENSION_MENTIONS,
|
||||
} from "@/core/extensions/mentions";
|
||||
import { useConversationReferencesCapability } from "@/core/features/hooks";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { polishInputDraft } from "@/core/input-polish/api";
|
||||
@ -169,6 +173,7 @@ import {
|
||||
type SlashCommandSuggestion,
|
||||
} from "./input-box-helpers";
|
||||
import {
|
||||
extensionMentionMetadata,
|
||||
inlineReferences,
|
||||
reconcileConversationReferences,
|
||||
MAX_EXPLICIT_SKILLS,
|
||||
@ -1463,12 +1468,21 @@ export function InputBox({
|
||||
toast.warning(t.inputBox.mentionMultipleSkills);
|
||||
return Promise.reject(new Error("Too many skill references."));
|
||||
}
|
||||
const extensionMetadata = extensionMentionMetadata(textInput.value);
|
||||
if (
|
||||
(extensionMetadata.extension_mentions?.length ?? 0) >
|
||||
MAX_EXTENSION_MENTIONS
|
||||
) {
|
||||
toast.warning(t.inputBox.mentionExtensionsLimit);
|
||||
return Promise.reject(new Error("Too many extension mentions"));
|
||||
}
|
||||
pendingDraftSubmissionRef.current = {
|
||||
key: draftKey,
|
||||
text: textInput.value,
|
||||
skillName: null,
|
||||
};
|
||||
const additionalKwargs = {
|
||||
...extensionMetadata,
|
||||
...(skillReferences.length
|
||||
? { skill_references: skillReferences }
|
||||
: {}),
|
||||
@ -1551,6 +1565,7 @@ export function InputBox({
|
||||
sidecar,
|
||||
t.inputBox.suggestionPlaceholderRequired,
|
||||
t.inputBox.mentionMultipleSkills,
|
||||
t.inputBox.mentionExtensionsLimit,
|
||||
conversationCapability,
|
||||
threadId,
|
||||
uploadLimits,
|
||||
@ -1845,7 +1860,9 @@ export function InputBox({
|
||||
? selection.skill.name
|
||||
: selection.kind === "conversation"
|
||||
? selection.reference.threadId
|
||||
: null;
|
||||
: selection.kind === "extension"
|
||||
? extensionMentionId(selection.reference)
|
||||
: null;
|
||||
const selected =
|
||||
selectionId &&
|
||||
inlineReferences(originalText).some(
|
||||
@ -1883,6 +1900,12 @@ export function InputBox({
|
||||
selection.reference.threadId,
|
||||
selection.reference.title,
|
||||
);
|
||||
if (selection.kind === "extension")
|
||||
token = referenceToken(
|
||||
"extension",
|
||||
extensionMentionId(selection.reference),
|
||||
selection.reference.label,
|
||||
);
|
||||
if (selection.kind === "file")
|
||||
token = referenceToken(
|
||||
"file",
|
||||
@ -2878,6 +2901,9 @@ export function InputBox({
|
||||
style={{ maxHeight: mentionPlacement.maxHeight }}
|
||||
>
|
||||
<MentionPicker
|
||||
selectedExtensions={inlineReferences(textInput.value)
|
||||
.filter((ref) => ref.kind === "extension")
|
||||
.map((ref) => ref.id)}
|
||||
ref={mentionPickerRef}
|
||||
listId={mentionListId}
|
||||
query={mentionQuery?.query ?? ""}
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
import type { ConversationReference } from "@/core/conversation-references";
|
||||
import {
|
||||
extensionMentionId,
|
||||
parseExtensionMention,
|
||||
} from "@/core/extensions/mentions";
|
||||
|
||||
export const MAX_EXPLICIT_SKILLS = 16;
|
||||
|
||||
export type InlineReference = {
|
||||
kind: "skill" | "file" | "conversation";
|
||||
kind: "skill" | "file" | "conversation" | "extension";
|
||||
id: string;
|
||||
label: string;
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
const pattern = /@\[([^\]]*)\]\(ref:(skill|file|conversation):([^)]*)\)/g;
|
||||
const pattern =
|
||||
/@\[([^\]]*)\]\(ref:(skill|file|conversation|extension):([^)]*)\)/g;
|
||||
|
||||
export function referenceToken(
|
||||
kind: InlineReference["kind"],
|
||||
@ -162,18 +167,28 @@ export function renderReferenceEditor(root: HTMLElement, text: string) {
|
||||
? "project-attachment-chip"
|
||||
: ref.kind === "conversation"
|
||||
? "conversation-reference-chip"
|
||||
: "inline-skill-reference";
|
||||
: ref.kind === "extension"
|
||||
? "extension-mention-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");
|
||||
: ref.kind === "extension"
|
||||
? "text-teal-600 dark:text-teal-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" ? "▤" : "◉";
|
||||
ref.kind === "skill"
|
||||
? "✦"
|
||||
: ref.kind === "file"
|
||||
? "▤"
|
||||
: ref.kind === "extension"
|
||||
? "◈"
|
||||
: "◉";
|
||||
token.append(icon, document.createTextNode(ref.label));
|
||||
token.setAttribute("aria-label", `@${ref.label}`);
|
||||
fragment.append(token);
|
||||
@ -233,3 +248,19 @@ export function reconcileConversationReferences(
|
||||
}
|
||||
return { text, references: [...references.values()] };
|
||||
}
|
||||
|
||||
/** Only references still present in the draft are submitted. */
|
||||
export function extensionMentionMetadata(text: string) {
|
||||
const references = new Map<
|
||||
string,
|
||||
NonNullable<ReturnType<typeof parseExtensionMention>>
|
||||
>();
|
||||
for (const ref of inlineReferences(text)) {
|
||||
if (ref.kind !== "extension") continue;
|
||||
const mention = parseExtensionMention(ref.id, ref.label);
|
||||
if (mention) references.set(extensionMentionId(mention), mention);
|
||||
}
|
||||
return references.size
|
||||
? { extension_mentions: [...references.values()] }
|
||||
: {};
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
CheckIcon,
|
||||
FileIcon,
|
||||
MessagesSquareIcon,
|
||||
PuzzleIcon,
|
||||
SparklesIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
@ -19,6 +20,12 @@ import {
|
||||
} from "react";
|
||||
|
||||
import type { ConversationReference } from "@/core/conversation-references";
|
||||
import type { ExtensionMention } from "@/core/extensions/contracts";
|
||||
import {
|
||||
extensionMentionId,
|
||||
MAX_EXTENSION_MENTIONS,
|
||||
} from "@/core/extensions/mentions";
|
||||
import { useExtensionMentions } from "@/core/extensions/use-mentions";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useInfiniteProjectDocuments } from "@/core/projects/hooks";
|
||||
import type { ProjectDocument } from "@/core/projects/types";
|
||||
@ -34,6 +41,7 @@ export type MentionSelection =
|
||||
| { kind: "skill"; skill: Skill }
|
||||
| { kind: "file"; document: ProjectDocument }
|
||||
| { kind: "conversation"; reference: ConversationReference }
|
||||
| { kind: "extension"; reference: ExtensionMention }
|
||||
| { kind: "upload" };
|
||||
export type MentionPickerHandle = {
|
||||
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
|
||||
@ -58,6 +66,7 @@ export function MentionPicker({
|
||||
capability,
|
||||
onActiveOptionChange,
|
||||
selectedSkills,
|
||||
selectedExtensions = [],
|
||||
references,
|
||||
threadId,
|
||||
projectId,
|
||||
@ -83,6 +92,7 @@ export function MentionPicker({
|
||||
};
|
||||
onActiveOptionChange?: (id: string | undefined) => void;
|
||||
selectedSkills?: string[];
|
||||
selectedExtensions?: string[];
|
||||
references: ConversationReference[];
|
||||
threadId: string;
|
||||
projectId?: string | null;
|
||||
@ -105,6 +115,10 @@ export function MentionPicker({
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const compositionEndedAt = useRef(-Infinity);
|
||||
const filter = (searchInput ? search : query).toLocaleLowerCase();
|
||||
const extensions = useExtensionMentions(
|
||||
searchInput ? search : query,
|
||||
threadId,
|
||||
);
|
||||
const options = useMemo(() => {
|
||||
const result: Option[] = skills
|
||||
.filter((skill) => skill.enabled)
|
||||
@ -161,6 +175,19 @@ export function MentionPicker({
|
||||
.toLocaleLowerCase()
|
||||
.includes(filter),
|
||||
);
|
||||
for (const item of extensions.items) {
|
||||
const id = extensionMentionId(item);
|
||||
filtered.push({
|
||||
id: `extension:${id}`,
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
selection: { kind: "extension", reference: item },
|
||||
selected: selectedExtensions.includes(id),
|
||||
disabled:
|
||||
!selectedExtensions.includes(id) &&
|
||||
new Set(selectedExtensions).size >= MAX_EXTENSION_MENTIONS,
|
||||
});
|
||||
}
|
||||
filtered.push({
|
||||
id: "upload",
|
||||
label: labels.mentionUpload,
|
||||
@ -168,6 +195,8 @@ export function MentionPicker({
|
||||
});
|
||||
return filtered;
|
||||
}, [
|
||||
extensions.items,
|
||||
selectedExtensions,
|
||||
skills,
|
||||
selectedSkills,
|
||||
documents.data,
|
||||
@ -230,11 +259,13 @@ export function MentionPicker({
|
||||
?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [activeOptionId]);
|
||||
const loading =
|
||||
extensions.loading ||
|
||||
skillsLoading ||
|
||||
capability.isLoading ||
|
||||
(!!projectId && documents.isPending) ||
|
||||
(capability.enabled && conversations.isPending);
|
||||
const failed =
|
||||
extensions.failed ||
|
||||
!!capability.error ||
|
||||
!!skillsError ||
|
||||
(!!projectId && documents.isError) ||
|
||||
@ -247,6 +278,7 @@ export function MentionPicker({
|
||||
title: labels.mentionConversations,
|
||||
Icon: MessagesSquareIcon,
|
||||
},
|
||||
{ kind: "extension", title: labels.mentionExtensions, Icon: PuzzleIcon },
|
||||
{ kind: "upload", title: "", Icon: UploadIcon },
|
||||
] as const;
|
||||
return (
|
||||
@ -364,6 +396,7 @@ export function MentionPicker({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (extensions.failed) extensions.retry();
|
||||
if (capability.error) void capability.refetch?.();
|
||||
if (skillsError) void onRetrySkills?.();
|
||||
if (projectId && documents.isError) void documents.refetch();
|
||||
|
||||
@ -72,12 +72,32 @@ export type ConversationActionGroup = {
|
||||
actions: ConversationAction[];
|
||||
};
|
||||
|
||||
export type ExtensionMention = {
|
||||
namespace: string;
|
||||
provider: string;
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
export type MentionContext = Pick<
|
||||
SurfaceContext,
|
||||
"namespace" | "locale" | "settings" | "threadId" | "signal" | "callBackend"
|
||||
>;
|
||||
export type MentionProvider = {
|
||||
id: string;
|
||||
label: string;
|
||||
search: (
|
||||
query: string,
|
||||
context: MentionContext,
|
||||
) => Promise<readonly { id: string; label: string; description?: string }[]>;
|
||||
};
|
||||
|
||||
/** Browser package API v1. Modules are installed by trusted deployment operators. */
|
||||
export interface FrontendExtension {
|
||||
apiVersion: 1;
|
||||
module: string;
|
||||
icon?: string;
|
||||
surfaces?: PluginSurface[];
|
||||
mentionProviders?: MentionProvider[];
|
||||
conversationActions?: (
|
||||
t: Translations,
|
||||
locale?: string,
|
||||
|
||||
138
frontend/src/core/extensions/mentions.ts
Normal file
138
frontend/src/core/extensions/mentions.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import type { ExtensionMention, MentionContext } from "./contracts";
|
||||
import { activeFrontendExtensions, type LoadedContribution } from "./registry";
|
||||
import {
|
||||
bindFrontendServices,
|
||||
conversationText,
|
||||
latestVisibleAnswer,
|
||||
} from "./services";
|
||||
|
||||
export const MAX_EXTENSION_MENTIONS = 16;
|
||||
const identifier = /^[a-z][a-z0-9-]{0,63}$/;
|
||||
const bounded = (value: unknown, limit: number): value is string =>
|
||||
typeof value === "string" && !!value.trim() && value.length <= limit;
|
||||
|
||||
export function extensionMentionId(ref: ExtensionMention) {
|
||||
return JSON.stringify([ref.namespace, ref.provider, ref.id]);
|
||||
}
|
||||
|
||||
/** Draft references and submitted metadata are untrusted labels, never authority. */
|
||||
export function parseExtensionMention(
|
||||
id: string,
|
||||
label: string,
|
||||
): ExtensionMention | null {
|
||||
try {
|
||||
if (id.length > 2048 || !bounded(label, 120)) return null;
|
||||
const parts: unknown = JSON.parse(id);
|
||||
if (!Array.isArray(parts) || parts.length !== 3) return null;
|
||||
const [namespace, provider, itemId] = parts as unknown[];
|
||||
if (
|
||||
typeof namespace !== "string" ||
|
||||
!/^[a-z][a-z0-9_.-]{0,127}$/.test(namespace) ||
|
||||
typeof provider !== "string" ||
|
||||
!identifier.test(provider) ||
|
||||
!bounded(itemId, 512)
|
||||
)
|
||||
return null;
|
||||
return { namespace, provider, id: itemId, label };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchExtensionMentions(
|
||||
entries: LoadedContribution[],
|
||||
query: string,
|
||||
context: Pick<MentionContext, "locale" | "threadId" | "signal">,
|
||||
): Promise<{
|
||||
items: (ExtensionMention & { description?: string })[];
|
||||
failed: boolean;
|
||||
}> {
|
||||
context.signal.throwIfAborted();
|
||||
const providers = activeFrontendExtensions(entries)
|
||||
.flatMap(({ contribution, extension }) =>
|
||||
(extension.mentionProviders ?? []).map((provider) => ({
|
||||
contribution,
|
||||
provider,
|
||||
})),
|
||||
)
|
||||
.slice(0, 16);
|
||||
const results = await Promise.all(
|
||||
providers.map(async ({ contribution, provider }) => {
|
||||
const abort = new AbortController();
|
||||
const cancel = () => abort.abort();
|
||||
context.signal.addEventListener("abort", cancel, { once: true });
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let onAbort: (() => void) | undefined;
|
||||
try {
|
||||
const deadline = new Promise<never>((_, reject) => {
|
||||
onAbort = () => reject(new Error("Mention search cancelled"));
|
||||
abort.signal.addEventListener("abort", onAbort, { once: true });
|
||||
timeout = setTimeout(() => abort.abort(), 3000);
|
||||
});
|
||||
const services = bindFrontendServices(
|
||||
{
|
||||
conversationText,
|
||||
latestVisibleAnswer,
|
||||
showMessage: () => undefined,
|
||||
},
|
||||
contribution,
|
||||
abort.signal,
|
||||
);
|
||||
const candidates = await Promise.race([
|
||||
Promise.resolve().then(() => {
|
||||
abort.signal.throwIfAborted();
|
||||
return provider.search(query.slice(0, 256), {
|
||||
...context,
|
||||
namespace: contribution.namespace,
|
||||
settings: contribution.settings,
|
||||
signal: abort.signal,
|
||||
callBackend: async (action, payload) => {
|
||||
abort.signal.throwIfAborted();
|
||||
const result = await services.callBackend(action, payload);
|
||||
abort.signal.throwIfAborted();
|
||||
return result;
|
||||
},
|
||||
});
|
||||
}),
|
||||
deadline,
|
||||
]);
|
||||
if (!Array.isArray(candidates))
|
||||
throw new Error("Invalid mention results");
|
||||
const seen = new Set<string>();
|
||||
const items: (ExtensionMention & { description?: string })[] = [];
|
||||
for (const candidate of candidates.slice(0, 50)) {
|
||||
if (
|
||||
!candidate ||
|
||||
!bounded(candidate.id, 512) ||
|
||||
!bounded(candidate.label, 120) ||
|
||||
seen.has(candidate.id)
|
||||
)
|
||||
continue;
|
||||
seen.add(candidate.id);
|
||||
items.push({
|
||||
namespace: contribution.namespace,
|
||||
provider: provider.id,
|
||||
id: candidate.id,
|
||||
label: candidate.label,
|
||||
description: bounded(candidate.description, 240)
|
||||
? candidate.description
|
||||
: provider.label,
|
||||
});
|
||||
}
|
||||
return { items, failed: false };
|
||||
} catch {
|
||||
return { items: [], failed: true };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (onAbort) abort.signal.removeEventListener("abort", onAbort);
|
||||
abort.abort();
|
||||
context.signal.removeEventListener("abort", cancel);
|
||||
}
|
||||
}),
|
||||
);
|
||||
context.signal.throwIfAborted();
|
||||
return {
|
||||
items: results.flatMap((result) => result.items),
|
||||
failed: results.some((result) => result.failed),
|
||||
};
|
||||
}
|
||||
@ -87,6 +87,28 @@ export async function loadFrontendExtensions(
|
||||
typeof loadedModule.conversationActions !== "function")
|
||||
)
|
||||
throw new Error("Incompatible browser extension");
|
||||
if (loadedModule.mentionProviders !== undefined) {
|
||||
const seen = new Set<string>();
|
||||
if (
|
||||
!Array.isArray(loadedModule.mentionProviders) ||
|
||||
loadedModule.mentionProviders.length > 8
|
||||
)
|
||||
throw new Error("Invalid mention providers");
|
||||
for (const provider of loadedModule.mentionProviders) {
|
||||
if (
|
||||
!provider ||
|
||||
typeof provider.id !== "string" ||
|
||||
!/^[a-z][a-z0-9-]{0,63}$/.test(provider.id) ||
|
||||
seen.has(provider.id) ||
|
||||
typeof provider.label !== "string" ||
|
||||
!provider.label.trim() ||
|
||||
provider.label.length > 120 ||
|
||||
typeof provider.search !== "function"
|
||||
)
|
||||
throw new Error("Invalid mention provider");
|
||||
seen.add(provider.id);
|
||||
}
|
||||
}
|
||||
if (loadedModule.surfaces !== undefined) {
|
||||
const seen = new Set<string>();
|
||||
if (
|
||||
@ -97,6 +119,7 @@ export async function loadFrontendExtensions(
|
||||
for (const surface of loadedModule.surfaces) {
|
||||
if (
|
||||
!surface ||
|
||||
typeof surface.id !== "string" ||
|
||||
!/^[a-z][a-z0-9-]{0,63}$/.test(surface.id) ||
|
||||
seen.has(surface.id) ||
|
||||
surface.slot !== "page" ||
|
||||
|
||||
83
frontend/src/core/extensions/use-mentions.ts
Normal file
83
frontend/src/core/extensions/use-mentions.ts
Normal file
@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import { useFrontendExtensions } from "./hooks";
|
||||
import { searchExtensionMentions } from "./mentions";
|
||||
|
||||
const empty = { items: [], failed: false };
|
||||
export function useExtensionMentions(query: string, threadId: string) {
|
||||
const { user } = useAuth();
|
||||
const { locale } = useI18n();
|
||||
const extensions = useFrontendExtensions();
|
||||
const [attempt, retry] = useState(0);
|
||||
const scope = useMemo(
|
||||
() => ({
|
||||
userId: user?.id,
|
||||
locale,
|
||||
threadId,
|
||||
entries: extensions.data,
|
||||
}),
|
||||
[user?.id, locale, threadId, extensions.data],
|
||||
);
|
||||
const hasProviders = (scope.entries ?? []).some(
|
||||
(entry) =>
|
||||
entry.viewer_id === scope.userId &&
|
||||
entry.settings.enabled === true &&
|
||||
(entry.extension?.mentionProviders?.length ?? 0) > 0,
|
||||
);
|
||||
const request = useMemo(
|
||||
() => ({ scope, query, attempt }),
|
||||
[scope, query, attempt],
|
||||
);
|
||||
const [state, setState] = useState<{
|
||||
request: typeof request;
|
||||
result: Awaited<ReturnType<typeof searchExtensionMentions>>;
|
||||
}>();
|
||||
useEffect(() => {
|
||||
if (!hasProviders) return;
|
||||
const abort = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
void searchExtensionMentions(
|
||||
(scope.entries ?? []).filter(
|
||||
(entry) => entry.viewer_id === scope.userId,
|
||||
),
|
||||
request.query,
|
||||
{
|
||||
locale: scope.locale,
|
||||
threadId: scope.threadId,
|
||||
signal: abort.signal,
|
||||
},
|
||||
)
|
||||
.then((result) => {
|
||||
if (!abort.signal.aborted) setState({ request, result });
|
||||
})
|
||||
.catch(() => {
|
||||
/* Unmount, account, thread, or query change. */
|
||||
});
|
||||
}, 150);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
abort.abort();
|
||||
};
|
||||
}, [scope, request, hasProviders]);
|
||||
// Keep settled suggestions during query/retry refreshes, but never across
|
||||
// viewer, thread, locale or installed-snapshot changes.
|
||||
const result = !hasProviders
|
||||
? empty
|
||||
: state?.request.scope === scope
|
||||
? state.result
|
||||
: undefined;
|
||||
return {
|
||||
...(result ?? empty),
|
||||
failed: extensions.isError || result?.failed === true,
|
||||
loading: extensions.isPending || result === undefined,
|
||||
retry: () => {
|
||||
void extensions.refetch();
|
||||
retry((value) => value + 1);
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -379,9 +379,11 @@ export const enUS: Translations = {
|
||||
// Input Box
|
||||
inputBox: {
|
||||
mentionPicker: "Add a reference",
|
||||
mentionSearch: "Search skills, project files and conversations",
|
||||
mentionSearch: "Search skills, project files, conversations and plugins",
|
||||
mentionSkills: "Skills",
|
||||
mentionFiles: "Project files",
|
||||
mentionExtensions: "Plugins",
|
||||
mentionExtensionsLimit: "Select up to 16 plugin references.",
|
||||
mentionConversations: "Conversations",
|
||||
mentionUpload: "Upload a file",
|
||||
mentionEmpty: "No matching references in loaded results",
|
||||
|
||||
@ -312,6 +312,8 @@ export interface Translations {
|
||||
mentionSearch: string;
|
||||
mentionSkills: string;
|
||||
mentionFiles: string;
|
||||
mentionExtensions: string;
|
||||
mentionExtensionsLimit: string;
|
||||
mentionConversations: string;
|
||||
mentionUpload: string;
|
||||
mentionEmpty: string;
|
||||
|
||||
@ -359,9 +359,11 @@ export const zhCN: Translations = {
|
||||
// Input Box
|
||||
inputBox: {
|
||||
mentionPicker: "添加引用",
|
||||
mentionSearch: "搜索技能、项目文件和对话",
|
||||
mentionSearch: "搜索技能、项目文件、对话和插件",
|
||||
mentionSkills: "技能",
|
||||
mentionFiles: "项目文件",
|
||||
mentionExtensions: "插件",
|
||||
mentionExtensionsLimit: "最多选择 16 个插件引用。",
|
||||
mentionConversations: "对话",
|
||||
mentionUpload: "上传文件",
|
||||
mentionEmpty: "已加载的内容中没有匹配的引用",
|
||||
|
||||
138
frontend/tests/e2e/extension-mentions.spec.ts
Normal file
138
frontend/tests/e2e/extension-mentions.spec.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
test("plugin candidates coexist with skills, survive draft reload and submit structured references", async ({
|
||||
page,
|
||||
baseURL,
|
||||
}, testInfo) => {
|
||||
mockLangGraphAPI(page, {
|
||||
skills: [
|
||||
{ name: "research", description: "Research a topic", enabled: true },
|
||||
],
|
||||
});
|
||||
const source = `export default { apiVersion: 1, module: 'team', mentionProviders: [
|
||||
{ id: 'people', label: 'Team members', search: async (query, ctx) => {
|
||||
const items = await ctx.callBackend('search', { query, threadId: ctx.threadId });
|
||||
return items;
|
||||
} },
|
||||
{ id: 'broken', label: 'Unavailable', search: async () => { throw Error('offline'); } }
|
||||
] };`;
|
||||
const entry = `/api/plugins/modules/team/${createHash("sha256").update(source).digest("hex")}.mjs`;
|
||||
let enabled = true;
|
||||
let holdSearch = false;
|
||||
let releaseSearch: (() => void) | undefined;
|
||||
const searches: unknown[] = [];
|
||||
await page.route("**/api/plugins**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const headers = {
|
||||
"access-control-allow-origin": new URL(baseURL!).origin,
|
||||
"access-control-allow-credentials": "true",
|
||||
};
|
||||
if (url.pathname.endsWith("/api/plugins"))
|
||||
return route.fulfill({
|
||||
headers,
|
||||
json: [
|
||||
{
|
||||
namespace: "community.team",
|
||||
viewer_id: "default",
|
||||
module: "team",
|
||||
entry,
|
||||
title: "Team",
|
||||
description: "",
|
||||
settings: { enabled },
|
||||
backend_actions: ["search"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (url.pathname.endsWith(entry))
|
||||
return route.fulfill({
|
||||
headers,
|
||||
contentType: "text/javascript",
|
||||
body: source,
|
||||
});
|
||||
if (url.pathname.endsWith("/actions/search")) {
|
||||
searches.push(route.request().postDataJSON());
|
||||
expect(route.request().headers()["x-deerflow-plugin-viewer"]).toBe(
|
||||
"default",
|
||||
);
|
||||
if (holdSearch) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseSearch = resolve;
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
headers,
|
||||
json: [{ id: "alice", label: "Alice" }],
|
||||
});
|
||||
}
|
||||
return route.fulfill({ status: 404, headers });
|
||||
});
|
||||
await page.goto("/workspace/chats/new");
|
||||
const input = page.getByRole("textbox", { name: /how can i assist you/i });
|
||||
await input.fill("Ask @");
|
||||
await expect(
|
||||
page.getByRole("option", { name: "research Research a topic" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("option", { name: "Alice Team members" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("listbox")).toHaveAttribute("aria-busy", "false");
|
||||
holdSearch = true;
|
||||
const refresh = page.waitForRequest((request) =>
|
||||
request.url().includes("/actions/search"),
|
||||
);
|
||||
await input.press("a");
|
||||
await refresh;
|
||||
try {
|
||||
await expect(
|
||||
page.getByRole("option", { name: "Alice Team members" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("listbox")).toHaveAttribute(
|
||||
"aria-busy",
|
||||
"false",
|
||||
);
|
||||
} finally {
|
||||
holdSearch = false;
|
||||
releaseSearch?.();
|
||||
}
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("extension-mentions.png"),
|
||||
});
|
||||
await page.getByRole("option", { name: "Alice Team members" }).click();
|
||||
await expect(page.getByTestId("extension-mention-chip")).toContainText(
|
||||
"Alice",
|
||||
);
|
||||
await expect(page.getByTestId("extension-mention-chip")).toContainText("◈");
|
||||
await expect.poll(() => searches.length).toBeGreaterThan(0);
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("extension-mention-chip")).toContainText(
|
||||
"Alice",
|
||||
);
|
||||
const request = page.waitForRequest(
|
||||
(r) => r.method() === "POST" && r.url().includes("/runs/stream"),
|
||||
);
|
||||
await input.press("Enter");
|
||||
const message = (await request).postDataJSON().input.messages.at(-1);
|
||||
expect(message.content).toEqual([{ type: "text", text: "Ask @Alice" }]);
|
||||
expect(message.additional_kwargs.extension_mentions).toEqual([
|
||||
{
|
||||
namespace: "community.team",
|
||||
provider: "people",
|
||||
id: "alice",
|
||||
label: "Alice",
|
||||
},
|
||||
]);
|
||||
enabled = false;
|
||||
await page.goto("/workspace/chats/new");
|
||||
await page.reload();
|
||||
await input.fill("@");
|
||||
await expect(
|
||||
page.getByRole("option", { name: "research Research a topic" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("option", { name: "Alice Team members" }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
@ -371,6 +371,17 @@ test.describe("Thread history", () => {
|
||||
await expect
|
||||
.poll(() => latestPageRequestCount, { timeout: 15_000 })
|
||||
.toBeGreaterThan(latestPageRequestsBeforeSubmit);
|
||||
// The refreshed page extends the virtualized list after submission. Keep
|
||||
// positioning its measured tail until the follow-up enters the DOM.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await scroller.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
return page.getByText(followUpPrompt).isVisible();
|
||||
})
|
||||
.toBe(true);
|
||||
await expect(page.getByText(followUpPrompt)).toBeVisible();
|
||||
|
||||
let preservedDurationFound = false;
|
||||
|
||||
@ -41,6 +41,33 @@ rs.mock("@/core/models/hooks", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const pluginSearch = rs.fn();
|
||||
rs.mock("@/core/extensions/hooks", () => ({
|
||||
useFrontendExtensions: () => ({
|
||||
data: pluginEntries,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
const pluginEntries = [
|
||||
{
|
||||
namespace: "community.team",
|
||||
viewer_id: "user-1",
|
||||
module: "team",
|
||||
entry: null,
|
||||
title: "Team",
|
||||
description: "",
|
||||
settings: { enabled: true },
|
||||
extension: {
|
||||
apiVersion: 1,
|
||||
module: "team",
|
||||
mentionProviders: [
|
||||
{ id: "people", label: "People", search: pluginSearch },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const skillCatalog = [
|
||||
{
|
||||
name: "research",
|
||||
@ -176,6 +203,8 @@ rs.mock("@/core/threads/hooks", () => ({
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
pluginSearch.mockReset();
|
||||
pluginSearch.mockResolvedValue([]);
|
||||
skillCatalog.splice(1);
|
||||
capability.enabled = true;
|
||||
capability.maxReferences = 3;
|
||||
@ -228,6 +257,52 @@ function enterMention(
|
||||
}
|
||||
|
||||
describe("unified composer mentions", () => {
|
||||
it("submits a plugin selection as readable text and structured metadata", async () => {
|
||||
pluginSearch.mockResolvedValue([{ id: "alice", label: "Alice" }]);
|
||||
const submit = rs.fn();
|
||||
const { container } = renderComposer("plugin-mention", submit);
|
||||
enterMention(container, "Ask @ali");
|
||||
fireEvent.click(
|
||||
await screen.findByRole("option", { name: "Alice People" }),
|
||||
);
|
||||
expect(screen.getByTestId("extension-mention-chip")).toBeTruthy();
|
||||
fireEvent.keyDown(container.querySelector('[contenteditable="true"]')!, {
|
||||
key: "Enter",
|
||||
});
|
||||
await waitFor(() => expect(submit).toHaveBeenCalled());
|
||||
expect(submit.mock.calls[0]![0].text).toBe("Ask @Alice ");
|
||||
expect(
|
||||
submit.mock.calls[0]![1].additionalKwargs.extension_mentions,
|
||||
).toEqual([
|
||||
{
|
||||
namespace: "community.team",
|
||||
provider: "people",
|
||||
id: "alice",
|
||||
label: "Alice",
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("drops metadata when a plugin reference is toggled off", async () => {
|
||||
pluginSearch.mockResolvedValue([{ id: "alice", label: "Alice" }]);
|
||||
const submit = rs.fn();
|
||||
const { container } = renderComposer("remove-plugin-mention", submit);
|
||||
enterMention(container, "Ask @ali");
|
||||
fireEvent.click(
|
||||
await screen.findByRole("option", { name: "Alice People" }),
|
||||
);
|
||||
enterMention(container, "@ali");
|
||||
fireEvent.click(
|
||||
await screen.findByRole("option", { name: "Alice People" }),
|
||||
);
|
||||
expect(screen.queryByTestId("extension-mention-chip")).toBeNull();
|
||||
fireEvent.keyDown(container.querySelector('[contenteditable="true"]')!, {
|
||||
key: "Enter",
|
||||
});
|
||||
await waitFor(() => expect(submit).toHaveBeenCalled());
|
||||
expect(
|
||||
submit.mock.calls[0]![1].additionalKwargs?.extension_mentions,
|
||||
).toBeUndefined();
|
||||
});
|
||||
it("clears cached conversation metadata when the draft owner changes", async () => {
|
||||
const submit = rs.fn();
|
||||
const rendered = renderComposer("cache-first", submit);
|
||||
|
||||
32
frontend/tests/unit/core/extensions/mentions.dom.test.ts
Normal file
32
frontend/tests/unit/core/extensions/mentions.dom.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { expect, it } from "@rstest/core";
|
||||
|
||||
import {
|
||||
referenceToken,
|
||||
renderReferenceEditor,
|
||||
} from "@/components/workspace/mentions/inline-references";
|
||||
import { extensionMentionId } from "@/core/extensions/mentions";
|
||||
|
||||
it("renders plugin references with a distinct glyph and color from conversations", () => {
|
||||
const root = document.createElement("div");
|
||||
const plugin = referenceToken(
|
||||
"extension",
|
||||
extensionMentionId({
|
||||
namespace: "community.team",
|
||||
provider: "people",
|
||||
id: "alice",
|
||||
label: "Alice",
|
||||
}),
|
||||
"Alice",
|
||||
);
|
||||
const conversation = referenceToken("conversation", "thread", "Conversation");
|
||||
renderReferenceEditor(root, `${plugin} ${conversation}`);
|
||||
const extensionChip = root.querySelector<HTMLElement>(
|
||||
'[data-reference-kind="extension"]',
|
||||
)!;
|
||||
const conversationChip = root.querySelector<HTMLElement>(
|
||||
'[data-reference-kind="conversation"]',
|
||||
)!;
|
||||
expect(extensionChip.firstElementChild?.textContent).toBe("◈");
|
||||
expect(conversationChip.firstElementChild?.textContent).toBe("◉");
|
||||
expect(extensionChip.className).not.toBe(conversationChip.className);
|
||||
});
|
||||
130
frontend/tests/unit/core/extensions/mentions.test.ts
Normal file
130
frontend/tests/unit/core/extensions/mentions.test.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { describe, expect, it, rs } from "@rstest/core";
|
||||
|
||||
import {
|
||||
extensionMentionMetadata,
|
||||
referenceToken,
|
||||
} from "@/components/workspace/mentions/inline-references";
|
||||
import {
|
||||
extensionMentionId,
|
||||
parseExtensionMention,
|
||||
searchExtensionMentions,
|
||||
} from "@/core/extensions/mentions";
|
||||
import type { LoadedContribution } from "@/core/extensions/registry";
|
||||
|
||||
const entry = (
|
||||
namespace: string,
|
||||
search: (...args: never[]) => unknown,
|
||||
): LoadedContribution => ({
|
||||
namespace,
|
||||
module: namespace,
|
||||
entry: null,
|
||||
title: namespace,
|
||||
description: "",
|
||||
settings: { enabled: true },
|
||||
extension: {
|
||||
apiVersion: 1,
|
||||
module: namespace,
|
||||
mentionProviders: [
|
||||
{ id: "people", label: "People", search: search as never },
|
||||
],
|
||||
},
|
||||
});
|
||||
describe("extension mentions", () => {
|
||||
it("keeps identities distinct and round trips Unicode and punctuation", () => {
|
||||
const reference = {
|
||||
namespace: "community.team",
|
||||
provider: "people",
|
||||
id: "陈 ()/1",
|
||||
label: "陈",
|
||||
};
|
||||
expect(
|
||||
parseExtensionMention(extensionMentionId(reference), reference.label),
|
||||
).toEqual(reference);
|
||||
expect(parseExtensionMention('["team","people",{}]', "Bad")).toBeNull();
|
||||
});
|
||||
it("isolates rejected providers and rejects invalid or duplicate candidates", async () => {
|
||||
const result = await searchExtensionMentions(
|
||||
[
|
||||
entry("broken", async () => {
|
||||
throw Error("offline");
|
||||
}),
|
||||
entry("team", async () => [
|
||||
{ id: "a", label: "Alice" },
|
||||
{ id: "a", label: "Duplicate" },
|
||||
{ id: {}, label: "Invalid" },
|
||||
]),
|
||||
{
|
||||
...entry("disabled", async () => [{ id: "b", label: "Bob" }]),
|
||||
settings: { enabled: false },
|
||||
},
|
||||
],
|
||||
"ali",
|
||||
{
|
||||
locale: "en-US",
|
||||
threadId: "thread",
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
);
|
||||
expect(result.items).toEqual([
|
||||
{
|
||||
namespace: "team",
|
||||
provider: "people",
|
||||
id: "a",
|
||||
label: "Alice",
|
||||
description: "People",
|
||||
},
|
||||
]);
|
||||
expect(result.failed).toBe(true);
|
||||
});
|
||||
it("settles even if a provider ignores cancellation", async () => {
|
||||
const abort = new AbortController();
|
||||
const pending = searchExtensionMentions(
|
||||
[entry("team", () => new Promise(() => undefined))],
|
||||
"",
|
||||
{ locale: "en-US", signal: abort.signal },
|
||||
);
|
||||
abort.abort();
|
||||
await expect(pending).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates selected tokens and drops removed or malformed metadata", () => {
|
||||
const reference = {
|
||||
namespace: "community.team",
|
||||
provider: "people",
|
||||
id: "陈/1",
|
||||
label: "陈",
|
||||
};
|
||||
const token = referenceToken(
|
||||
"extension",
|
||||
extensionMentionId(reference),
|
||||
reference.label,
|
||||
);
|
||||
expect(extensionMentionMetadata(`${token} ${token}`)).toEqual({
|
||||
extension_mentions: [reference],
|
||||
});
|
||||
expect(extensionMentionMetadata("@陈")).toEqual({});
|
||||
expect(
|
||||
extensionMentionMetadata(referenceToken("extension", "not-json", "Bad")),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("times out an unresponsive provider without losing healthy candidates", async () => {
|
||||
rs.useFakeTimers();
|
||||
try {
|
||||
const pending = searchExtensionMentions(
|
||||
[
|
||||
entry("slow", () => new Promise(() => undefined)),
|
||||
entry("fast", async () => [{ id: "one", label: "Ready" }]),
|
||||
],
|
||||
"",
|
||||
{ locale: "en-US", signal: new AbortController().signal },
|
||||
);
|
||||
await rs.advanceTimersByTimeAsync(3000);
|
||||
const result = await pending;
|
||||
expect(result.items.map((item) => item.id)).toEqual(["one"]);
|
||||
expect(result.failed).toBe(true);
|
||||
} finally {
|
||||
rs.useRealTimers();
|
||||
}
|
||||
});
|
||||
@ -191,3 +191,100 @@ test("discovery preserves transport negotiation through parsing and isolates new
|
||||
expect(loaded[1]?.error).toBeTruthy();
|
||||
expect(assetImporter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("validates optional mention providers independently for each installed module", async () => {
|
||||
const assets = {
|
||||
...entry,
|
||||
transport: "assets-v1",
|
||||
entry: `/api/plugins/${entry.namespace}/assets/${"b".repeat(64)}/index.mjs`,
|
||||
};
|
||||
const provider = { id: "people", label: "People", search: async () => [] };
|
||||
for (const mentionProviders of [
|
||||
null,
|
||||
[provider, provider],
|
||||
[{ ...provider, search: null }],
|
||||
[{ ...provider, id: "../bad" }],
|
||||
Array(9).fill(provider),
|
||||
]) {
|
||||
const importer = rs
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ default: { ...extension, mentionProviders } })
|
||||
.mockResolvedValueOnce({
|
||||
default: { ...extension, mentionProviders: [provider] },
|
||||
});
|
||||
const loaded = await loadFrontendExtensions(
|
||||
[assets, assets],
|
||||
rs.fn(),
|
||||
importer,
|
||||
);
|
||||
expect(loaded[0]?.error).toBeTruthy();
|
||||
expect(loaded[1]?.extension?.mentionProviders).toEqual([provider]);
|
||||
}
|
||||
});
|
||||
|
||||
for (const field of ["mentionProviders", "surfaces"] as const) {
|
||||
for (const [name, id] of [
|
||||
["omitted", undefined],
|
||||
["null", null],
|
||||
["boolean", true],
|
||||
["array", ["people"]],
|
||||
["object", { toString: () => "people" }],
|
||||
] as const) {
|
||||
test(`rejects ${name} ${field} identifiers without coercing them`, async () => {
|
||||
const assets = {
|
||||
...entry,
|
||||
transport: "assets-v1" as const,
|
||||
entry: `/api/plugins/${entry.namespace}/assets/${"b".repeat(64)}/index.mjs`,
|
||||
};
|
||||
const declaration =
|
||||
field === "mentionProviders"
|
||||
? { label: "People", search: async () => [] }
|
||||
: { slot: "page", title: "People", mount: () => undefined };
|
||||
const invalid = { ...declaration, ...(id === undefined ? {} : { id }) };
|
||||
const importer = rs
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
default: { ...extension, [field]: [invalid] },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
default: {
|
||||
...extension,
|
||||
[field]: [{ ...declaration, id: "people" }],
|
||||
},
|
||||
});
|
||||
const loaded = await loadFrontendExtensions(
|
||||
[assets, assets],
|
||||
rs.fn(),
|
||||
importer,
|
||||
);
|
||||
expect(loaded[0]?.error).toBeTruthy();
|
||||
expect(loaded[0]?.extension).toBeUndefined();
|
||||
expect(loaded[1]?.extension?.[field]?.[0]?.id).toBe("people");
|
||||
});
|
||||
}
|
||||
|
||||
test(`accepts literal null and undefined slug strings for ${field}`, async () => {
|
||||
const assets = {
|
||||
...entry,
|
||||
transport: "assets-v1" as const,
|
||||
entry: `/api/plugins/${entry.namespace}/assets/${"b".repeat(64)}/index.mjs`,
|
||||
};
|
||||
const declarations = ["null", "undefined"].map((id) =>
|
||||
field === "mentionProviders"
|
||||
? { id, label: "People", search: async () => [] }
|
||||
: { id, slot: "page", title: "People", mount: () => undefined },
|
||||
);
|
||||
const loaded = await loadFrontendExtensions(
|
||||
[assets],
|
||||
rs.fn(),
|
||||
async () => ({
|
||||
default: { ...extension, [field]: declarations },
|
||||
}),
|
||||
);
|
||||
expect(loaded[0]?.error).toBeUndefined();
|
||||
expect(loaded[0]?.extension?.[field]?.map((value) => value.id)).toEqual([
|
||||
"null",
|
||||
"undefined",
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
224
frontend/tests/unit/core/extensions/use-mentions.dom.test.tsx
Normal file
224
frontend/tests/unit/core/extensions/use-mentions.dom.test.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
import { afterEach, expect, it, rs } from "@rstest/core";
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
|
||||
import type { LoadedContribution } from "@/core/extensions/registry";
|
||||
import { useExtensionMentions } from "@/core/extensions/use-mentions";
|
||||
|
||||
let viewer = "alice";
|
||||
let locale = "en-US";
|
||||
let discoveryPending = false;
|
||||
let discoveryError = false;
|
||||
const search = rs.fn();
|
||||
const initialEntries: LoadedContribution[] = [
|
||||
{
|
||||
namespace: "test.team",
|
||||
viewer_id: viewer,
|
||||
module: "team",
|
||||
entry: null,
|
||||
title: "Team",
|
||||
description: "",
|
||||
settings: { enabled: true },
|
||||
extension: {
|
||||
apiVersion: 1,
|
||||
module: "team",
|
||||
mentionProviders: [{ id: "members", label: "Members", search }],
|
||||
},
|
||||
},
|
||||
];
|
||||
let entries = initialEntries;
|
||||
rs.mock("@/core/auth/AuthProvider", () => ({
|
||||
useAuth: () => ({ user: { id: viewer } }),
|
||||
}));
|
||||
rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ locale }) }));
|
||||
rs.mock("@/core/extensions/hooks", () => ({
|
||||
useFrontendExtensions: () => ({
|
||||
data: entries,
|
||||
isPending: discoveryPending,
|
||||
isError: discoveryError,
|
||||
}),
|
||||
}));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
search.mockReset();
|
||||
viewer = "alice";
|
||||
locale = "en-US";
|
||||
discoveryPending = false;
|
||||
discoveryError = false;
|
||||
entries = initialEntries;
|
||||
rs.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"empty",
|
||||
"backend-only",
|
||||
"no contribution",
|
||||
"empty providers",
|
||||
"disabled",
|
||||
"other viewer",
|
||||
])("settles immediately without a search timer for %s", (scenario) => {
|
||||
const entry = initialEntries[0]!;
|
||||
if (scenario === "empty") entries = [];
|
||||
if (scenario === "backend-only")
|
||||
entries = [{ ...entry, module: null, extension: undefined }];
|
||||
if (scenario === "no contribution")
|
||||
entries = [{ ...entry, extension: { apiVersion: 1, module: "team" } }];
|
||||
if (scenario === "empty providers")
|
||||
entries = [
|
||||
{
|
||||
...entry,
|
||||
extension: { apiVersion: 1, module: "team", mentionProviders: [] },
|
||||
},
|
||||
];
|
||||
if (scenario === "disabled")
|
||||
entries = [{ ...entry, settings: { enabled: false } }];
|
||||
if (scenario === "other viewer") entries = [{ ...entry, viewer_id: "bob" }];
|
||||
const timer = rs.spyOn(globalThis, "setTimeout");
|
||||
const { result, rerender } = renderHook(
|
||||
({ query }) => useExtensionMentions(query, "thread"),
|
||||
{ initialProps: { query: "a" } },
|
||||
);
|
||||
expect(result.current.items).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.failed).toBe(false);
|
||||
rerender({ query: "al" });
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(timer.mock.calls.some(([, delay]) => delay === 150)).toBe(false);
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps discovery loading and failure states when no providers are known", () => {
|
||||
entries = [];
|
||||
discoveryPending = true;
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useExtensionMentions("a", "thread"),
|
||||
);
|
||||
expect(result.current.loading).toBe(true);
|
||||
discoveryPending = false;
|
||||
discoveryError = true;
|
||||
rerender();
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.failed).toBe(true);
|
||||
});
|
||||
|
||||
it("clears a removed provider immediately and ignores its late response", async () => {
|
||||
let release!: (value: { id: string; label: string }[]) => void;
|
||||
search.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useExtensionMentions("a", "thread"),
|
||||
);
|
||||
expect(result.current.loading).toBe(true);
|
||||
await waitFor(() => expect(search).toHaveBeenCalledTimes(1));
|
||||
entries = [];
|
||||
rerender();
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.items).toEqual([]);
|
||||
await act(async () => release([{ id: "late", label: "Late" }]));
|
||||
expect(result.current.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains settled candidates during a query refresh without a picker-wide loading flash", async () => {
|
||||
let release!: (value: { id: string; label: string }[]) => void;
|
||||
search.mockResolvedValueOnce([{ id: "old", label: "Alice" }]);
|
||||
search.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const { result, rerender } = renderHook(
|
||||
({ query }) => useExtensionMentions(query, "thread"),
|
||||
{ initialProps: { query: "a" } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.items[0]?.id).toBe("old"));
|
||||
rerender({ query: "al" });
|
||||
expect(result.current.items[0]?.id).toBe("old");
|
||||
expect(result.current.loading).toBe(false);
|
||||
await waitFor(() => expect(search).toHaveBeenCalledTimes(2));
|
||||
expect(result.current.items[0]?.id).toBe("old");
|
||||
await act(async () => {
|
||||
release([{ id: "new", label: "Alison" }]);
|
||||
});
|
||||
expect(result.current.items[0]?.id).toBe("new");
|
||||
});
|
||||
|
||||
it.each(["viewer", "thread", "locale", "entries"])(
|
||||
"clears settled candidates immediately when %s changes",
|
||||
async (change) => {
|
||||
search.mockResolvedValueOnce([{ id: "old", label: "Alice" }]);
|
||||
search.mockImplementation(() => new Promise(() => undefined));
|
||||
const { result, rerender } = renderHook(
|
||||
({ threadId }) => useExtensionMentions("a", threadId),
|
||||
{ initialProps: { threadId: "one" } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.items[0]?.id).toBe("old"));
|
||||
if (change === "viewer") viewer = "bob";
|
||||
if (change === "locale") locale = "zh-CN";
|
||||
if (change === "entries") entries = [...entries];
|
||||
rerender({ threadId: change === "thread" ? "two" : "one" });
|
||||
expect(result.current.items).toEqual([]);
|
||||
expect(result.current.loading).toBe(change !== "viewer");
|
||||
},
|
||||
);
|
||||
|
||||
it("does not replace fresh same-context results with a late superseded query", async () => {
|
||||
let release!: (value: { id: string; label: string }[]) => void;
|
||||
search.mockResolvedValueOnce([{ id: "first", label: "First" }]);
|
||||
search.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
search.mockResolvedValueOnce([{ id: "latest", label: "Latest" }]);
|
||||
const { result, rerender } = renderHook(
|
||||
({ query }) => useExtensionMentions(query, "thread"),
|
||||
{ initialProps: { query: "first" } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.items[0]?.id).toBe("first"));
|
||||
rerender({ query: "slow" });
|
||||
await waitFor(() => expect(search).toHaveBeenCalledTimes(2));
|
||||
rerender({ query: "latest" });
|
||||
await waitFor(() => expect(result.current.items[0]?.id).toBe("latest"));
|
||||
await act(async () => {
|
||||
release([{ id: "stale", label: "Stale" }]);
|
||||
});
|
||||
expect(result.current.items[0]?.id).toBe("latest");
|
||||
});
|
||||
it("cancels old queries and fences results across query, thread, viewer and unmount", async () => {
|
||||
let release!: (value: { id: string; label: string }[]) => void;
|
||||
let signal!: AbortSignal;
|
||||
search.mockImplementationOnce((_query, context) => {
|
||||
signal = context.signal;
|
||||
return new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
});
|
||||
search.mockResolvedValue([{ id: "new", label: "Current" }]);
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ query, threadId }) => useExtensionMentions(query, threadId),
|
||||
{ initialProps: { query: "old", threadId: "one" } },
|
||||
);
|
||||
await waitFor(() => expect(search).toHaveBeenCalledTimes(1));
|
||||
rerender({ query: "new", threadId: "two" });
|
||||
expect(signal.aborted).toBe(true);
|
||||
await waitFor(() => expect(result.current.items[0]?.id).toBe("new"));
|
||||
await act(async () => {
|
||||
release([{ id: "old", label: "Stale" }]);
|
||||
});
|
||||
expect(result.current.items[0]?.id).toBe("new");
|
||||
viewer = "bob";
|
||||
rerender({ query: "new", threadId: "two" });
|
||||
expect(result.current.items).toEqual([]);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.items).toEqual([]);
|
||||
entries = entries.map((entry) => ({ ...entry, viewer_id: viewer }));
|
||||
rerender({ query: "last", threadId: "three" });
|
||||
await waitFor(() => expect(search).toHaveBeenCalledTimes(3));
|
||||
unmount();
|
||||
expect(search.mock.calls[2]![1].signal.aborted).toBe(true);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user