From 59b051b0fd3147af852411c0ea759bcbd10f09d4 Mon Sep 17 00:00:00 2001 From: Wenchao An Date: Sat, 3 Oct 2026 20:30:23 +0800 Subject: [PATCH] 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 --- docs/full-stack-plugins.md | 56 +++++ frontend/src/AGENTS.md | 13 + .../src/components/workspace/input-box.tsx | 28 ++- .../workspace/mentions/inline-references.ts | 41 +++- .../workspace/mentions/mention-picker.tsx | 33 +++ frontend/src/core/extensions/contracts.ts | 20 ++ frontend/src/core/extensions/mentions.ts | 138 +++++++++++ frontend/src/core/extensions/registry.ts | 23 ++ frontend/src/core/extensions/use-mentions.ts | 83 +++++++ frontend/src/core/i18n/locales/en-US.ts | 4 +- frontend/src/core/i18n/locales/types.ts | 2 + frontend/src/core/i18n/locales/zh-CN.ts | 4 +- frontend/tests/e2e/extension-mentions.spec.ts | 138 +++++++++++ frontend/tests/e2e/thread-history.spec.ts | 11 + .../workspace/input-box-mentions.dom.test.tsx | 75 ++++++ .../unit/core/extensions/mentions.dom.test.ts | 32 +++ .../unit/core/extensions/mentions.test.ts | 130 ++++++++++ .../unit/core/extensions/registry.test.ts | 97 ++++++++ .../core/extensions/use-mentions.dom.test.tsx | 224 ++++++++++++++++++ 19 files changed, 1144 insertions(+), 8 deletions(-) create mode 100644 frontend/src/core/extensions/mentions.ts create mode 100644 frontend/src/core/extensions/use-mentions.ts create mode 100644 frontend/tests/e2e/extension-mentions.spec.ts create mode 100644 frontend/tests/unit/core/extensions/mentions.dom.test.ts create mode 100644 frontend/tests/unit/core/extensions/mentions.test.ts create mode 100644 frontend/tests/unit/core/extensions/use-mentions.dom.test.tsx diff --git a/docs/full-stack-plugins.md b/docs/full-stack-plugins.md index 35020fb1f6..694c8752f4 100644 --- a/docs/full-stack-plugins.md +++ b/docs/full-stack-plugins.md @@ -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. diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 1b4d8c562b..d40cd73500 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -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. diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index c06b9f05eb..7ccdd5a972 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -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 }} > ref.kind === "extension") + .map((ref) => ref.id)} ref={mentionPickerRef} listId={mentionListId} query={mentionQuery?.query ?? ""} diff --git a/frontend/src/components/workspace/mentions/inline-references.ts b/frontend/src/components/workspace/mentions/inline-references.ts index 06bc97ae79..c78ccb0f27 100644 --- a/frontend/src/components/workspace/mentions/inline-references.ts +++ b/frontend/src/components/workspace/mentions/inline-references.ts @@ -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> + >(); + 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()] } + : {}; +} diff --git a/frontend/src/components/workspace/mentions/mention-picker.tsx b/frontend/src/components/workspace/mentions/mention-picker.tsx index 899dfffcf3..c5dad9d74d 100644 --- a/frontend/src/components/workspace/mentions/mention-picker.tsx +++ b/frontend/src/components/workspace/mentions/mention-picker.tsx @@ -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) => 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(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({