From 6578832e61856afcc9ac0d204308b8867841b567 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 21 Aug 2026 03:54:23 -0700 Subject: [PATCH 01/10] Rank exact matches across mention sources --- apps/app/src/hooks/usePromptMentions.ts | 14 ++-- .../client-core/src/prompt/mentions/types.ts | 70 +++++++++++++++++++ .../test/mention-suggestion-order.test.ts | 58 +++++++++++++++ 3 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 packages/client-core/test/mention-suggestion-order.test.ts diff --git a/apps/app/src/hooks/usePromptMentions.ts b/apps/app/src/hooks/usePromptMentions.ts index 0bc14a8019..b79b6258c5 100644 --- a/apps/app/src/hooks/usePromptMentions.ts +++ b/apps/app/src/hooks/usePromptMentions.ts @@ -22,7 +22,10 @@ import { usePathSuggestions, PATH_SUGGESTION_DEBOUNCE_MS, } from "./usePathSuggestions"; -import type { PromptMentionSuggestion } from "@bb/client-core"; +import { + orderMentionSuggestions, + type PromptMentionSuggestion, +} from "@bb/client-core"; import { DEFAULT_PLUGIN_MENTION_TRIGGER, PLUGIN_MENTION_TRIGGER_VALUES, @@ -64,11 +67,9 @@ interface BuildPromptMentionSuggestionsArgs { function buildPromptMentionSuggestions( args: BuildPromptMentionSuggestionsArgs, ): PromptMentionSuggestion[] { - // A query containing "/" reads as a file path, so paths lead; otherwise the - // named entities (threads then projects) lead and paths trail. Plugin - // provider rows always trail the built-in sources (they render in their - // own labeled sections at the bottom of the menu). - return args.trimmedQuery.includes("/") + // A query containing "/" reads as a file path, so paths win relevance ties; + // otherwise the existing named-entity order remains the fallback. + const sourceOrdered = args.trimmedQuery.includes("/") ? [ ...args.pathSuggestions, ...args.threadSuggestions, @@ -83,6 +84,7 @@ function buildPromptMentionSuggestions( ...args.pathSuggestions, ...args.pluginSuggestions, ]; + return orderMentionSuggestions(sourceOrdered, args.trimmedQuery); } function buildProjectNamesById( diff --git a/packages/client-core/src/prompt/mentions/types.ts b/packages/client-core/src/prompt/mentions/types.ts index ab195dd2fd..879f918e2c 100644 --- a/packages/client-core/src/prompt/mentions/types.ts +++ b/packages/client-core/src/prompt/mentions/types.ts @@ -72,6 +72,76 @@ export type PromptMentionSuggestion = replacement: string; }; +function mentionSuggestionSearchNames( + suggestion: PromptMentionSuggestion, +): string[] { + if (suggestion.kind === "thread") { + return [suggestion.title ?? "", suggestion.threadId]; + } + if (suggestion.kind === "project") { + return [suggestion.name, suggestion.projectId]; + } + if (suggestion.kind === "section") { + return [suggestion.name, suggestion.sectionId]; + } + if (suggestion.kind === "plugin") { + return [suggestion.title]; + } + return [suggestion.name, suggestion.path, suggestion.replacement]; +} + +function mentionSuggestionMatchRank( + suggestion: PromptMentionSuggestion, + normalizedQuery: string, +): number { + if (normalizedQuery.length === 0) return 0; + const names = mentionSuggestionSearchNames(suggestion).map((name) => + name.trim().toLowerCase(), + ); + if (names.includes(normalizedQuery)) return 0; + return names.some((name) => name.startsWith(normalizedQuery)) ? 1 : 2; +} + +function mentionSuggestionSectionKey( + suggestion: PromptMentionSuggestion, +): string { + if (suggestion.kind === "path") return `path:${suggestion.source}`; + if (suggestion.kind === "plugin") { + return `plugin:${suggestion.pluginId}:${suggestion.providerId}`; + } + return suggestion.kind; +} + +/** + * Rank mention rows by how directly the query names them, then keep every + * rendered section contiguous under its strongest row. Stable input order is + * the tie-breaker, so callers retain their default source order when match + * quality is equal. + */ +export function orderMentionSuggestions( + suggestions: readonly PromptMentionSuggestion[], + query: string, +): PromptMentionSuggestion[] { + const normalizedQuery = query.trim().toLowerCase(); + const ranked = [...suggestions].sort( + (left, right) => + mentionSuggestionMatchRank(left, normalizedQuery) - + mentionSuggestionMatchRank(right, normalizedQuery), + ); + + const bySection = new Map(); + for (const suggestion of ranked) { + const section = mentionSuggestionSectionKey(suggestion); + const existing = bySection.get(section); + if (existing) { + existing.push(suggestion); + continue; + } + bySection.set(section, [suggestion]); + } + return [...bySection.values()].flat(); +} + /** * One row in the command typeahead menu, derived from a {@link ProviderCommand} * returned by `GET /projects/:id/commands`. The `kind: "command"` discriminant diff --git a/packages/client-core/test/mention-suggestion-order.test.ts b/packages/client-core/test/mention-suggestion-order.test.ts new file mode 100644 index 0000000000..1d113a3afb --- /dev/null +++ b/packages/client-core/test/mention-suggestion-order.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + orderMentionSuggestions, + type PromptMentionSuggestion, +} from "../src/index.js"; + +function thread(title: string): PromptMentionSuggestion { + return { + kind: "thread", + path: "thread:t", + replacement: "thread:t", + projectId: "p", + threadId: "t", + title, + }; +} + +function plugin( + title: string, + providerId = "installed", +): PromptMentionSuggestion { + return { + kind: "plugin", + pluginId: "at-plugin", + providerId, + itemId: `${providerId}:${title}`, + providerLabel: providerId, + title, + subtitle: null, + icon: null, + replacement: title, + }; +} + +describe("orderMentionSuggestions", () => { + it("puts an exact match above a prefix match from an earlier section", () => { + expect( + orderMentionSuggestions( + [thread("Plugin migration"), plugin("Plugin")], + " PLUGIN ", + ).map((suggestion) => suggestion.replacement), + ).toEqual(["Plugin", "thread:t"]); + }); + + it("keeps sections contiguous under their strongest match", () => { + expect( + orderMentionSuggestions( + [ + thread("Plugin migration"), + plugin("Plugin"), + plugin("Plugin Guide"), + plugin("Plugin Shop", "community"), + ], + "plugin", + ).map((suggestion) => suggestion.replacement), + ).toEqual(["Plugin", "Plugin Guide", "thread:t", "Plugin Shop"]); + }); +}); From 2635f4dfca4c9b8162a1006e61cf74404d621a3b Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 21 Aug 2026 08:17:50 -0700 Subject: [PATCH 02/10] Preserve plugin identities in mention ranking --- .../promptbox/PromptBoxInternal.test.tsx | 1 + .../promptbox/mentions/Mentions.stories.tsx | 4 ++ .../hooks/pluginMentionSuggestions.test.ts | 7 ++ .../app/src/hooks/pluginMentionSuggestions.ts | 1 + .../plugin-contribution-queries.test.tsx | 1 + .../queries/plugin-contribution-queries.ts | 64 +++++++++++++------ .../plugins/plugin-service-internal.ts | 1 + .../src/services/plugins/plugin-service.ts | 32 ++++++++++ .../bb-plugin-authoring/SKILL.md | 12 +++- .../services/plugins/heroes-phase2.test.ts | 1 + .../plugins/plugin-mention-providers.test.ts | 58 ++++++++++++++++- docs/api_to_audit.md | 13 ++++ .../client-core/src/prompt/mentions/types.ts | 4 +- .../test/mention-suggestion-order.test.ts | 14 ++++ packages/plugin-sdk/src/backend-contract.ts | 6 ++ 15 files changed, 196 insertions(+), 23 deletions(-) diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 2562192eb3..b6bf7a7564 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -2787,6 +2787,7 @@ describe("PromptBoxInternal mention triggers", () => { itemId: "issue:owner/repo#42", providerLabel: "GitHub issues", title: "#42 Fix login bug", + searchAliases: [], subtitle: "owner/repo", icon: null, replacement: "#42 Fix login bug", diff --git a/apps/app/src/components/promptbox/mentions/Mentions.stories.tsx b/apps/app/src/components/promptbox/mentions/Mentions.stories.tsx index f9108eaf57..371d01a823 100644 --- a/apps/app/src/components/promptbox/mentions/Mentions.stories.tsx +++ b/apps/app/src/components/promptbox/mentions/Mentions.stories.tsx @@ -191,6 +191,7 @@ const pluginMentionSuggestions: PromptMentionSuggestion[] = [ itemId: "issues:ISS-42", providerLabel: "Linear issues", title: "Fix login bug", + searchAliases: [], subtitle: "In progress", icon: null, replacement: "Fix login bug", @@ -202,6 +203,7 @@ const pluginMentionSuggestions: PromptMentionSuggestion[] = [ itemId: "issues:ISS-51", providerLabel: "Linear issues", title: "Ship mention providers end-to-end", + searchAliases: [], subtitle: "Todo", icon: null, replacement: "Ship mention providers end-to-end", @@ -213,6 +215,7 @@ const pluginMentionSuggestions: PromptMentionSuggestion[] = [ itemId: "docs:onboarding", providerLabel: "Linear docs", title: "Onboarding guide", + searchAliases: [], subtitle: null, icon: null, replacement: "Onboarding guide", @@ -227,6 +230,7 @@ const pluginMentionSuggestions: PromptMentionSuggestion[] = [ itemId: "issues:MIR-7", providerLabel: "Linear issues", title: "Mirrored triage sweep", + searchAliases: [], subtitle: "Mirror", icon: null, replacement: "Mirrored triage sweep", diff --git a/apps/app/src/hooks/pluginMentionSuggestions.test.ts b/apps/app/src/hooks/pluginMentionSuggestions.test.ts index 2bb802ffcb..34a8405523 100644 --- a/apps/app/src/hooks/pluginMentionSuggestions.test.ts +++ b/apps/app/src/hooks/pluginMentionSuggestions.test.ts @@ -11,12 +11,14 @@ const GROUPS: PluginMentionSearchGroup[] = [ { itemId: "issues:ISS-42", title: "Fix login bug", + searchAliases: ["ISS-42"], subtitle: "In progress", icon: "FileText", }, { itemId: "issues:ISS-43", title: "Ship mention providers", + searchAliases: [], subtitle: null, icon: null, }, @@ -30,6 +32,7 @@ const GROUPS: PluginMentionSearchGroup[] = [ { itemId: "docs:onboarding", title: "Onboarding", + searchAliases: [], subtitle: null, icon: null, }, @@ -47,6 +50,7 @@ describe("buildPluginMentionSuggestions", () => { itemId: "issues:ISS-42", providerLabel: "Linear issues", title: "Fix login bug", + searchAliases: ["ISS-42"], subtitle: "In progress", icon: "FileText", replacement: "Fix login bug", @@ -58,6 +62,7 @@ describe("buildPluginMentionSuggestions", () => { itemId: "issues:ISS-43", providerLabel: "Linear issues", title: "Ship mention providers", + searchAliases: [], subtitle: null, icon: null, replacement: "Ship mention providers", @@ -69,6 +74,7 @@ describe("buildPluginMentionSuggestions", () => { itemId: "docs:onboarding", providerLabel: "Docs", title: "Onboarding", + searchAliases: [], subtitle: null, icon: null, replacement: "Onboarding", @@ -87,6 +93,7 @@ describe("buildPluginMentionSuggestions", () => { { itemId: "issues:blank", title: " ", + searchAliases: [], subtitle: null, icon: null, }, diff --git a/apps/app/src/hooks/pluginMentionSuggestions.ts b/apps/app/src/hooks/pluginMentionSuggestions.ts index 184557c605..dad160e17f 100644 --- a/apps/app/src/hooks/pluginMentionSuggestions.ts +++ b/apps/app/src/hooks/pluginMentionSuggestions.ts @@ -23,6 +23,7 @@ export function buildPluginMentionSuggestions( itemId: item.itemId, providerLabel: group.label, title, + searchAliases: item.searchAliases, subtitle: item.subtitle, icon: item.icon, replacement: title, diff --git a/apps/app/src/hooks/queries/plugin-contribution-queries.test.tsx b/apps/app/src/hooks/queries/plugin-contribution-queries.test.tsx index 15d8397730..68c35f5137 100644 --- a/apps/app/src/hooks/queries/plugin-contribution-queries.test.tsx +++ b/apps/app/src/hooks/queries/plugin-contribution-queries.test.tsx @@ -155,6 +155,7 @@ describe("usePluginMentionSearch", () => { { itemId: "issue:owner/repo#42", title: "#42 Fix login bug", + searchAliases: [], subtitle: "owner/repo", icon: null, }, diff --git a/apps/app/src/hooks/queries/plugin-contribution-queries.ts b/apps/app/src/hooks/queries/plugin-contribution-queries.ts index f4abd34d31..33170b7965 100644 --- a/apps/app/src/hooks/queries/plugin-contribution-queries.ts +++ b/apps/app/src/hooks/queries/plugin-contribution-queries.ts @@ -88,6 +88,7 @@ interface PluginMentionSearchItem { /** Opaque server-composed item reference; rides the mention resource. */ itemId: string; title: string; + searchAliases: readonly string[]; subtitle: string | null; icon: string | null; } @@ -100,29 +101,50 @@ export interface PluginMentionSearchGroup { items: PluginMentionSearchItem[]; } -function isMentionSearchItem(value: unknown): value is PluginMentionSearchItem { - if (typeof value !== "object" || value === null) return false; +function toMentionSearchItem(value: unknown): PluginMentionSearchItem | null { + if (typeof value !== "object" || value === null) return null; const item = value as Record; - return ( - typeof item.itemId === "string" && - typeof item.title === "string" && - (item.subtitle === null || typeof item.subtitle === "string") && - (item.icon === null || typeof item.icon === "string") - ); + const searchAliases = item.searchAliases ?? []; + if ( + typeof item.itemId !== "string" || + typeof item.title !== "string" || + !Array.isArray(searchAliases) || + !searchAliases.every((alias) => typeof alias === "string") || + (item.subtitle !== null && typeof item.subtitle !== "string") || + (item.icon !== null && typeof item.icon !== "string") + ) { + return null; + } + return { + itemId: item.itemId, + title: item.title, + searchAliases, + subtitle: item.subtitle, + icon: item.icon, + }; } -function isMentionSearchGroup( - value: unknown, -): value is PluginMentionSearchGroup { - if (typeof value !== "object" || value === null) return false; +function toMentionSearchGroup(value: unknown): PluginMentionSearchGroup | null { + if (typeof value !== "object" || value === null) return null; const group = value as Record; - return ( - typeof group.pluginId === "string" && - typeof group.providerId === "string" && - typeof group.label === "string" && - Array.isArray(group.items) && - group.items.every(isMentionSearchItem) - ); + if ( + typeof group.pluginId !== "string" || + typeof group.providerId !== "string" || + typeof group.label !== "string" || + !Array.isArray(group.items) + ) { + return null; + } + const items = group.items.map(toMentionSearchItem); + if (items.some((item) => item === null)) return null; + return { + pluginId: group.pluginId, + providerId: group.providerId, + label: group.label, + items: items.filter( + (item): item is PluginMentionSearchItem => item !== null, + ), + }; } interface PluginMentionSearchArgs { @@ -151,7 +173,9 @@ async function fetchPluginMentionSearch( if (!response.ok) return []; const body = (await response.json()) as { groups?: unknown }; return Array.isArray(body.groups) - ? body.groups.filter(isMentionSearchGroup) + ? body.groups + .map(toMentionSearchGroup) + .filter((group): group is PluginMentionSearchGroup => group !== null) : []; } diff --git a/apps/server/src/services/plugins/plugin-service-internal.ts b/apps/server/src/services/plugins/plugin-service-internal.ts index deda418269..93dec4b8ef 100644 --- a/apps/server/src/services/plugins/plugin-service-internal.ts +++ b/apps/server/src/services/plugins/plugin-service-internal.ts @@ -210,6 +210,7 @@ export interface PluginMentionProviderContribution { export interface PluginMentionSearchItem { itemId: string; title: string; + searchAliases: string[]; subtitle: string | null; icon: string | null; } diff --git a/apps/server/src/services/plugins/plugin-service.ts b/apps/server/src/services/plugins/plugin-service.ts index 3156ca3192..daf7852e56 100644 --- a/apps/server/src/services/plugins/plugin-service.ts +++ b/apps/server/src/services/plugins/plugin-service.ts @@ -721,6 +721,32 @@ function normalizeAgentToolResult( * runs this inside invokeWrapped so they count as handler errors and the * provider contributes an empty group. */ +const MAX_MENTION_SEARCH_ALIASES = 8; +const MAX_MENTION_SEARCH_ALIAS_BYTES = 256; + +function normalizeMentionSearchAliases(args: { + providerId: string; + itemIndex: number; + value: unknown; +}): string[] { + if (args.value === undefined) return []; + if ( + !Array.isArray(args.value) || + args.value.length > MAX_MENTION_SEARCH_ALIASES || + args.value.some( + (alias) => + typeof alias !== "string" || + alias.trim().length === 0 || + Buffer.byteLength(alias, "utf8") > MAX_MENTION_SEARCH_ALIAS_BYTES, + ) + ) { + throw new Error( + `mention provider "${args.providerId}" items[${args.itemIndex}].experimental_searchAliases must contain at most ${MAX_MENTION_SEARCH_ALIASES} non-empty strings of at most ${MAX_MENTION_SEARCH_ALIAS_BYTES} UTF-8 bytes each`, + ); + } + return [...args.value]; +} + function normalizeMentionSearchItems( providerId: string, result: unknown, @@ -734,6 +760,7 @@ function normalizeMentionSearchItems( const typed = item as { id?: unknown; title?: unknown; + experimental_searchAliases?: unknown; subtitle?: unknown; icon?: unknown; } | null; @@ -752,6 +779,11 @@ function normalizeMentionSearchItems( return { itemId: `${providerId}:${typed.id}`, title: typed.title, + searchAliases: normalizeMentionSearchAliases({ + providerId, + itemIndex: index, + value: typed.experimental_searchAliases, + }), subtitle: typeof typed.subtitle === "string" && typed.subtitle.trim().length > 0 ? typed.subtitle diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index d3aec96948..b1ff85b1fa 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -1420,7 +1420,14 @@ bb.ui.registerMentionProvider({ triggers: ["@", "#"], // optional; defaults to ["@"]. Valid: @ # $ ! ~ search({ trigger, query, projectId, threadId }) { // 2s time box, failure = empty list - return [{ id: "42", title: "ENG-42 Fix flake", subtitle: "Todo" }]; + return [ + { + id: "42", + title: "Fix flake", + experimental_searchAliases: ["ENG-42"], + subtitle: "Todo", + }, + ]; }, resolve(itemId) { // once per unique item AT SEND TIME @@ -1431,6 +1438,9 @@ bb.ui.registerMentionProvider({ Thread actions render in the thread header; mention items render under `label` in the menu for each registered trigger. All handlers run server-side. +Use `experimental_searchAliases` for bounded non-visible identities that should +participate in host-owned exact/prefix ranking; providers never send numeric +ranks. There is deliberately no plugin slash-command surface: the composer's `/` menu lists skills, so a plugin capability that crafts a prompt for the agent ships as a `skills/` entry instead. diff --git a/apps/server/test/services/plugins/heroes-phase2.test.ts b/apps/server/test/services/plugins/heroes-phase2.test.ts index f17d138c64..df43a8e108 100644 --- a/apps/server/test/services/plugins/heroes-phase2.test.ts +++ b/apps/server/test/services/plugins/heroes-phase2.test.ts @@ -206,6 +206,7 @@ describe("hero plugin: agent-enrichment (Phase 2 surfaces)", () => { { itemId: "docs:testing.md", title: "Testing", + searchAliases: [], subtitle: "testing.md", icon: null, }, diff --git a/apps/server/test/services/plugins/plugin-mention-providers.test.ts b/apps/server/test/services/plugins/plugin-mention-providers.test.ts index a6632ae4ad..915b3c473d 100644 --- a/apps/server/test/services/plugins/plugin-mention-providers.test.ts +++ b/apps/server/test/services/plugins/plugin-mention-providers.test.ts @@ -56,6 +56,8 @@ const MENTION_SOURCE = ` { id: "ISS-42", title: "Fix login bug", + experimental_searchAliases: ["ISS-42", "linear-fix"], + rank: -100, subtitle: "ctx:" + ctx.trigger + ":" + ctx.query + ":" + ctx.projectId + ":" + ctx.threadId, }, { id: "ISS-43", title: "Ship mention providers" }, @@ -231,6 +233,7 @@ describe("plugin mention providers (bb.ui.registerMentionProvider)", () => { { itemId: "issues:ISS-42", title: "Fix login bug", + searchAliases: ["ISS-42", "linear-fix"], // The provider saw the forwarded query + project/thread context. subtitle: "ctx:@:fix:proj_1:thr_1", icon: null, @@ -238,6 +241,7 @@ describe("plugin mention providers (bb.ui.registerMentionProvider)", () => { { itemId: "issues:ISS-43", title: "Ship mention providers", + searchAliases: [], subtitle: null, icon: null, }, @@ -251,6 +255,7 @@ describe("plugin mention providers (bb.ui.registerMentionProvider)", () => { { itemId: "docs:onboarding", title: "Onboarding guide", + searchAliases: [], subtitle: null, icon: null, }, @@ -280,12 +285,14 @@ describe("plugin mention providers (bb.ui.registerMentionProvider)", () => { { itemId: "issues:ISS-42", title: "Fix login bug", + searchAliases: ["ISS-42", "linear-fix"], subtitle: "ctx:#:fix:proj_1:thr_1", icon: null, }, { itemId: "issues:ISS-43", title: "Ship mention providers", + searchAliases: [], subtitle: null, icon: null, }, @@ -294,6 +301,49 @@ describe("plugin mention providers (bb.ui.registerMentionProvider)", () => { ]); }); + it("bounds provider search aliases and drops an invalid provider group", async () => { + const rootDir = await writePlugin( + join(harness.config.dataDir, "fixtures"), + { + name: "bb-plugin-too-many-mention-aliases", + serverSource: ` + export default function plugin(bb: any) { + bb.ui.registerMentionProvider({ + id: "aliases", + label: "Aliases", + search: () => [{ + id: "one", + title: "One", + experimental_searchAliases: Array.from({ length: 9 }, (_, index) => "alias-" + index), + }], + resolve: () => ({ context: "one" }), + }); + } + `, + }, + ); + const installed = await harness.pluginService.installPath(rootDir); + expect(installed.status).toBe("running"); + + const response = await harness.app.request( + `${BASE}/api/v1/plugins/mentions/search?q=one`, + ); + const body = (await response.json()) as { + groups: Array<{ pluginId: string }>; + }; + expect( + body.groups.some( + (group) => group.pluginId === "too-many-mention-aliases", + ), + ).toBe(false); + expect( + harness.pluginService + .list() + .find((plugin) => plugin.id === "too-many-mention-aliases") + ?.handlerStats.errorCount, + ).toBe(1); + }); + it("rejects invalid search trigger params", async () => { const response = await harness.app.request( `${BASE}/api/v1/plugins/mentions/search?q=fix&trigger=%3F`, @@ -688,7 +738,13 @@ describe("mention search time box", () => { providerId: "fast", label: "Fast", items: [ - { itemId: "fast:one", title: "One", subtitle: null, icon: null }, + { + itemId: "fast:one", + title: "One", + searchAliases: [], + subtitle: null, + icon: null, + }, ], }, ]); diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 6dd4bc6d29..4678d3ea03 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -218,6 +218,19 @@ clients fall back). Settle whether a row persisted with a namespaced glyph should ever be rewritten when the plugin renames or removes the icon; today rows are never rewritten and simply fall back. +## Mention search aliases (`PluginMentionItem.experimental_searchAliases`) + +**What it does.** Lets a mention provider attach up to eight non-visible search +names to a result, each bounded to 256 UTF-8 bytes. bb combines those aliases +with the visible title to compute exact, prefix, and other cross-source +relevance on desktop and mobile. Providers cannot supply numeric ranks. + +**Audit before stabilizing.** Confirm the count and byte limits against real +identity shapes; decide whether aliases should remain provider-authored or +become a host-owned identity field; verify case folding and whitespace rules +for non-English names; and confirm exact aliases should continue outranking +weaker built-in matches without changing within-section ordering. + ## `experimental_buildBridgeToolCallContent` **Kept experimental (2026-08-22).** it still accepts two input shapes (ordered `contentBlocks` and the legacy aggregate `{ content, images }`) though every first-party caller now passes the ordered form, and no image MIME/size policy exists at the server boundary; drop the legacy input and settle the policy, then stabilize. diff --git a/packages/client-core/src/prompt/mentions/types.ts b/packages/client-core/src/prompt/mentions/types.ts index 879f918e2c..9b17aa8533 100644 --- a/packages/client-core/src/prompt/mentions/types.ts +++ b/packages/client-core/src/prompt/mentions/types.ts @@ -66,6 +66,8 @@ export type PromptMentionSuggestion = itemId: string; providerLabel: string; title: string; + /** Non-visible provider identities used only for host-owned ranking. */ + searchAliases: readonly string[]; subtitle: string | null; /** Named shared-UI icon hint supplied by the plugin item. */ icon: string | null; @@ -85,7 +87,7 @@ function mentionSuggestionSearchNames( return [suggestion.name, suggestion.sectionId]; } if (suggestion.kind === "plugin") { - return [suggestion.title]; + return [suggestion.title, ...suggestion.searchAliases]; } return [suggestion.name, suggestion.path, suggestion.replacement]; } diff --git a/packages/client-core/test/mention-suggestion-order.test.ts b/packages/client-core/test/mention-suggestion-order.test.ts index 1d113a3afb..e543709ab8 100644 --- a/packages/client-core/test/mention-suggestion-order.test.ts +++ b/packages/client-core/test/mention-suggestion-order.test.ts @@ -18,6 +18,7 @@ function thread(title: string): PromptMentionSuggestion { function plugin( title: string, providerId = "installed", + searchAliases: readonly string[] = [], ): PromptMentionSuggestion { return { kind: "plugin", @@ -26,6 +27,7 @@ function plugin( itemId: `${providerId}:${title}`, providerLabel: providerId, title, + searchAliases, subtitle: null, icon: null, replacement: title, @@ -42,6 +44,18 @@ describe("orderMentionSuggestions", () => { ).toEqual(["Plugin", "thread:t"]); }); + it("uses plugin identity aliases without accepting a provider rank", () => { + expect( + orderMentionSuggestions( + [ + thread("at-plugin migration"), + plugin("Plugin Focus", "installed", ["at-plugin"]), + ], + "at-plugin", + ).map((suggestion) => suggestion.replacement), + ).toEqual(["Plugin Focus", "thread:t"]); + }); + it("keeps sections contiguous under their strongest match", () => { expect( orderMentionSuggestions( diff --git a/packages/plugin-sdk/src/backend-contract.ts b/packages/plugin-sdk/src/backend-contract.ts index c536f00a40..de4e123440 100644 --- a/packages/plugin-sdk/src/backend-contract.ts +++ b/packages/plugin-sdk/src/backend-contract.ts @@ -1000,6 +1000,12 @@ export interface PluginMentionSearchContext { export interface PluginMentionItem { id: string; title: string; + /** + * Additional non-visible names the host may use to rank this item against + * other mention sources. The host computes relevance itself; providers do + * not supply numeric ranks. At most 8 aliases of 256 UTF-8 bytes each. + */ + experimental_searchAliases?: readonly string[]; subtitle?: string; icon?: string; } From 282d57356affc5c538148e8599ae33d00635e00e Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 21 Aug 2026 08:53:22 -0700 Subject: [PATCH 03/10] Format plugin identity plumbing --- apps/app/src/hooks/queries/plugin-contribution-queries.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/app/src/hooks/queries/plugin-contribution-queries.ts b/apps/app/src/hooks/queries/plugin-contribution-queries.ts index 33170b7965..f0125f1ad4 100644 --- a/apps/app/src/hooks/queries/plugin-contribution-queries.ts +++ b/apps/app/src/hooks/queries/plugin-contribution-queries.ts @@ -24,7 +24,6 @@ interface PluginContributions { mentionProviders: PluginMentionProviderContribution[]; } - const EMPTY_CONTRIBUTIONS: PluginContributions = { mentionProviders: [], }; From 9eeb9c0b48abe619cda5a414d5a8804691f1ff70 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:21:08 +0000 Subject: [PATCH 04/10] Generalize mention candidate ranking --- packages/client-core/src/index.ts | 1 + .../src/prompt/mentions/mention-candidates.ts | 140 +++++++++++ .../client-core/src/prompt/mentions/types.ts | 70 ------ .../test/mention-suggestion-order.test.ts | 223 ++++++++++++++---- 4 files changed, 316 insertions(+), 118 deletions(-) create mode 100644 packages/client-core/src/prompt/mentions/mention-candidates.ts diff --git a/packages/client-core/src/index.ts b/packages/client-core/src/index.ts index a57b6fc07b..007768fb79 100644 --- a/packages/client-core/src/index.ts +++ b/packages/client-core/src/index.ts @@ -32,6 +32,7 @@ export * from "./prompt/effective-prompt-mode.js"; export * from "./prompt/permission-mode-options.js"; export * from "./prompt/mentions/plugin-mention-triggers.js"; export * from "./prompt/mentions/types.js"; +export * from "./prompt/mentions/mention-candidates.js"; export * from "./prompt/mentions/find-active-trigger.js"; export * from "./prompt/mentions/command-trigger.js"; export * from "./prompt/fork-thread-request.js"; diff --git a/packages/client-core/src/prompt/mentions/mention-candidates.ts b/packages/client-core/src/prompt/mentions/mention-candidates.ts new file mode 100644 index 0000000000..196814e6bc --- /dev/null +++ b/packages/client-core/src/prompt/mentions/mention-candidates.ts @@ -0,0 +1,140 @@ +import type { PromptMentionSuggestion } from "./types.js"; + +/** + * One source-normalized mention result. Callers own the resource-specific + * mapping into visible identity, aliases, supporting text, and a rendered + * group. Client core owns all relevance decisions after that boundary. + */ +export interface MentionCandidate { + suggestion: PromptMentionSuggestion; + visibleTitle: string; + /** Additional identities, such as a resource id or provider search alias. */ + identityTerms: readonly string[]; + supportingTerms: readonly string[]; + groupKey: string; + groupLabel: string; +} + +/** One intact rendered group after relevance ordering. */ +export interface OrderedMentionSuggestionGroup { + key: string; + label: string; + suggestions: readonly PromptMentionSuggestion[]; +} + +/** + * The exact mention order shared by grouped rendering and flat keyboard + * navigation. `suggestions` is the concatenation of `groups` in order. + */ +export interface OrderedMentionSuggestions { + groups: readonly OrderedMentionSuggestionGroup[]; + suggestions: readonly PromptMentionSuggestion[]; +} + +interface RankedMentionCandidate { + candidate: MentionCandidate; + inputIndex: number; + matchRank: number; +} + +interface RankedMentionCandidateGroup { + key: string; + label: string; + inputIndex: number; + bestMatchRank: number; + candidates: RankedMentionCandidate[]; +} + +function normalizeMentionTerm(term: string): string { + return term.trim().toLowerCase(); +} + +function mentionCandidateMatchRank( + candidate: MentionCandidate, + normalizedQuery: string, +): number { + if (normalizedQuery.length === 0) return 0; + + const identities = [candidate.visibleTitle, ...candidate.identityTerms].map( + normalizeMentionTerm, + ); + if (identities.some((identity) => identity === normalizedQuery)) return 0; + if (identities.some((identity) => identity.startsWith(normalizedQuery))) { + return 1; + } + if (identities.some((identity) => identity.includes(normalizedQuery))) { + return 2; + } + + const hasSupportingMatch = candidate.supportingTerms + .map(normalizeMentionTerm) + .some((term) => term.includes(normalizedQuery)); + return hasSupportingMatch ? 3 : 4; +} + +function compareRankedMentionCandidates( + left: RankedMentionCandidate, + right: RankedMentionCandidate, +): number { + const byMatch = left.matchRank - right.matchRank; + return byMatch !== 0 ? byMatch : left.inputIndex - right.inputIndex; +} + +function compareRankedMentionCandidateGroups( + left: RankedMentionCandidateGroup, + right: RankedMentionCandidateGroup, +): number { + const byBestMatch = left.bestMatchRank - right.bestMatchRank; + return byBestMatch !== 0 ? byBestMatch : left.inputIndex - right.inputIndex; +} + +/** + * Rank intact source groups by their strongest row, and rank rows within each + * group by exact identity, identity prefix, identity substring, then + * supporting-text match. Original source order is the final tie-breaker. + */ +export function orderMentionCandidates( + candidates: readonly MentionCandidate[], + query: string, +): OrderedMentionSuggestions { + const normalizedQuery = normalizeMentionTerm(query); + const groupsByKey = new Map(); + + for (const [inputIndex, candidate] of candidates.entries()) { + const rankedCandidate: RankedMentionCandidate = { + candidate, + inputIndex, + matchRank: mentionCandidateMatchRank(candidate, normalizedQuery), + }; + const existingGroup = groupsByKey.get(candidate.groupKey); + if (existingGroup) { + existingGroup.bestMatchRank = Math.min( + existingGroup.bestMatchRank, + rankedCandidate.matchRank, + ); + existingGroup.candidates.push(rankedCandidate); + continue; + } + + groupsByKey.set(candidate.groupKey, { + key: candidate.groupKey, + label: candidate.groupLabel, + inputIndex, + bestMatchRank: rankedCandidate.matchRank, + candidates: [rankedCandidate], + }); + } + + const groups = [...groupsByKey.values()] + .sort(compareRankedMentionCandidateGroups) + .map((group) => ({ + key: group.key, + label: group.label, + suggestions: group.candidates + .sort(compareRankedMentionCandidates) + .map(({ candidate }) => candidate.suggestion), + })); + const suggestions = groups.flatMap((group) => group.suggestions); + + return { groups, suggestions }; +} diff --git a/packages/client-core/src/prompt/mentions/types.ts b/packages/client-core/src/prompt/mentions/types.ts index 9b17aa8533..f0389d5857 100644 --- a/packages/client-core/src/prompt/mentions/types.ts +++ b/packages/client-core/src/prompt/mentions/types.ts @@ -74,76 +74,6 @@ export type PromptMentionSuggestion = replacement: string; }; -function mentionSuggestionSearchNames( - suggestion: PromptMentionSuggestion, -): string[] { - if (suggestion.kind === "thread") { - return [suggestion.title ?? "", suggestion.threadId]; - } - if (suggestion.kind === "project") { - return [suggestion.name, suggestion.projectId]; - } - if (suggestion.kind === "section") { - return [suggestion.name, suggestion.sectionId]; - } - if (suggestion.kind === "plugin") { - return [suggestion.title, ...suggestion.searchAliases]; - } - return [suggestion.name, suggestion.path, suggestion.replacement]; -} - -function mentionSuggestionMatchRank( - suggestion: PromptMentionSuggestion, - normalizedQuery: string, -): number { - if (normalizedQuery.length === 0) return 0; - const names = mentionSuggestionSearchNames(suggestion).map((name) => - name.trim().toLowerCase(), - ); - if (names.includes(normalizedQuery)) return 0; - return names.some((name) => name.startsWith(normalizedQuery)) ? 1 : 2; -} - -function mentionSuggestionSectionKey( - suggestion: PromptMentionSuggestion, -): string { - if (suggestion.kind === "path") return `path:${suggestion.source}`; - if (suggestion.kind === "plugin") { - return `plugin:${suggestion.pluginId}:${suggestion.providerId}`; - } - return suggestion.kind; -} - -/** - * Rank mention rows by how directly the query names them, then keep every - * rendered section contiguous under its strongest row. Stable input order is - * the tie-breaker, so callers retain their default source order when match - * quality is equal. - */ -export function orderMentionSuggestions( - suggestions: readonly PromptMentionSuggestion[], - query: string, -): PromptMentionSuggestion[] { - const normalizedQuery = query.trim().toLowerCase(); - const ranked = [...suggestions].sort( - (left, right) => - mentionSuggestionMatchRank(left, normalizedQuery) - - mentionSuggestionMatchRank(right, normalizedQuery), - ); - - const bySection = new Map(); - for (const suggestion of ranked) { - const section = mentionSuggestionSectionKey(suggestion); - const existing = bySection.get(section); - if (existing) { - existing.push(suggestion); - continue; - } - bySection.set(section, [suggestion]); - } - return [...bySection.values()].flat(); -} - /** * One row in the command typeahead menu, derived from a {@link ProviderCommand} * returned by `GET /projects/:id/commands`. The `kind: "command"` discriminant diff --git a/packages/client-core/test/mention-suggestion-order.test.ts b/packages/client-core/test/mention-suggestion-order.test.ts index e543709ab8..039651f9de 100644 --- a/packages/client-core/test/mention-suggestion-order.test.ts +++ b/packages/client-core/test/mention-suggestion-order.test.ts @@ -1,72 +1,199 @@ import { describe, expect, it } from "vitest"; import { - orderMentionSuggestions, + orderMentionCandidates, + type MentionCandidate, + type OrderedMentionSuggestions, type PromptMentionSuggestion, } from "../src/index.js"; -function thread(title: string): PromptMentionSuggestion { +interface CandidateOptions { + name: string; + visibleTitle: string; + identityTerms?: readonly string[]; + supportingTerms?: readonly string[]; + groupKey?: string; + groupLabel?: string; +} + +function thread(name: string): PromptMentionSuggestion { return { kind: "thread", - path: "thread:t", - replacement: "thread:t", + path: `thread:${name}`, + replacement: name, projectId: "p", - threadId: "t", - title, + threadId: name, + title: name, }; } -function plugin( - title: string, - providerId = "installed", - searchAliases: readonly string[] = [], -): PromptMentionSuggestion { +function candidate(options: CandidateOptions): MentionCandidate { return { - kind: "plugin", - pluginId: "at-plugin", - providerId, - itemId: `${providerId}:${title}`, - providerLabel: providerId, - title, - searchAliases, - subtitle: null, - icon: null, - replacement: title, + suggestion: thread(options.name), + visibleTitle: options.visibleTitle, + identityTerms: options.identityTerms ?? [], + supportingTerms: options.supportingTerms ?? [], + groupKey: options.groupKey ?? options.name, + groupLabel: options.groupLabel ?? options.groupKey ?? options.name, }; } -describe("orderMentionSuggestions", () => { - it("puts an exact match above a prefix match from an earlier section", () => { +function suggestionNames(results: OrderedMentionSuggestions): string[] { + return results.suggestions.map((suggestion) => suggestion.replacement); +} + +describe("orderMentionCandidates", () => { + it("orders exact, prefix, substring, and supporting-text matches", () => { + const candidates = [ + candidate({ + name: "supporting", + visibleTitle: "Migration notes", + supportingTerms: ["Plugin documentation"], + groupKey: "results", + }), + candidate({ + name: "substring", + visibleTitle: "At Plugin Toolkit", + groupKey: "results", + }), + candidate({ + name: "prefix", + visibleTitle: "Plugin migration", + groupKey: "results", + }), + candidate({ + name: "exact", + visibleTitle: "Plugin", + groupKey: "results", + }), + ]; + expect( - orderMentionSuggestions( - [thread("Plugin migration"), plugin("Plugin")], - " PLUGIN ", - ).map((suggestion) => suggestion.replacement), - ).toEqual(["Plugin", "thread:t"]); + suggestionNames(orderMentionCandidates(candidates, " PLUGIN ")), + ).toEqual(["exact", "prefix", "substring", "supporting"]); }); - it("uses plugin identity aliases without accepting a provider rank", () => { + it("treats caller-supplied aliases as identities for any suggestion kind", () => { + const candidates = [ + candidate({ + name: "title-prefix", + visibleTitle: "At Plugin migration", + }), + candidate({ + name: "alias-exact", + visibleTitle: "Plugin Focus", + identityTerms: ["at-plugin"], + }), + ]; + expect( - orderMentionSuggestions( - [ - thread("at-plugin migration"), - plugin("Plugin Focus", "installed", ["at-plugin"]), - ], - "at-plugin", - ).map((suggestion) => suggestion.replacement), - ).toEqual(["Plugin Focus", "thread:t"]); + suggestionNames(orderMentionCandidates(candidates, "at-plugin")), + ).toEqual(["alias-exact", "title-prefix"]); }); - it("keeps sections contiguous under their strongest match", () => { + it("orders intact groups by their strongest candidate", () => { + const candidates = [ + candidate({ + name: "early-supporting", + visibleTitle: "Migration notes", + supportingTerms: ["Plugin"], + groupKey: "early", + groupLabel: "Early", + }), + candidate({ + name: "strong-prefix", + visibleTitle: "Plugin guide", + groupKey: "strong", + groupLabel: "Strong", + }), + candidate({ + name: "strong-weak", + visibleTitle: "Unrelated", + groupKey: "strong", + groupLabel: "Strong", + }), + candidate({ + name: "early-substring", + visibleTitle: "My Plugin notes", + groupKey: "early", + groupLabel: "Early", + }), + ]; + + const results = orderMentionCandidates(candidates, "plugin"); + + expect(results.groups.map((group) => group.key)).toEqual([ + "strong", + "early", + ]); expect( - orderMentionSuggestions( - [ - thread("Plugin migration"), - plugin("Plugin"), - plugin("Plugin Guide"), - plugin("Plugin Shop", "community"), - ], - "plugin", - ).map((suggestion) => suggestion.replacement), - ).toEqual(["Plugin", "Plugin Guide", "thread:t", "Plugin Shop"]); + results.groups.map((group) => + group.suggestions.map((item) => item.replacement), + ), + ).toEqual([ + ["strong-prefix", "strong-weak"], + ["early-substring", "early-supporting"], + ]); + }); + + it("uses the same exact order for groups and keyboard navigation", () => { + const results = orderMentionCandidates( + [ + candidate({ + name: "first-prefix", + visibleTitle: "Plugin guide", + groupKey: "first", + groupLabel: "First label", + }), + candidate({ + name: "first-exact", + visibleTitle: "Plugin", + groupKey: "first", + groupLabel: "First label", + }), + candidate({ + name: "second-substring", + visibleTitle: "My Plugin", + groupKey: "second", + groupLabel: "Second label", + }), + ], + "plugin", + ); + + expect(results.groups.map((group) => [group.key, group.label])).toEqual([ + ["first", "First label"], + ["second", "Second label"], + ]); + expect(suggestionNames(results)).toEqual( + results.groups + .flatMap((group) => group.suggestions) + .map((suggestion) => suggestion.replacement), + ); + }); + + it("preserves source group and candidate order for an empty query", () => { + const candidates = [ + candidate({ + name: "first-a", + visibleTitle: "Zed", + groupKey: "first", + }), + candidate({ + name: "first-b", + visibleTitle: "Alpha", + groupKey: "first", + }), + candidate({ + name: "second-a", + visibleTitle: "Beta", + groupKey: "second", + }), + ]; + + expect(suggestionNames(orderMentionCandidates(candidates, " "))).toEqual([ + "first-a", + "first-b", + "second-a", + ]); }); }); From bbf34d7960901ca6c5506efec32d552c4b5f4d5d Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:43:09 +0000 Subject: [PATCH 05/10] Wire generic mention result model --- apps/app/.ladle/story-fixtures.ts | 3 +- .../promptbox/FollowUpPromptBox.stories.tsx | 3 +- .../promptbox/FollowUpPromptBox.test.tsx | 3 +- .../promptbox/NewThreadComposer.tsx | 2 +- .../promptbox/PromptBoxAppShortcuts.test.tsx | 3 +- .../PromptBoxInternal.ipados.test.tsx | 7 +- .../promptbox/PromptBoxInternal.stories.tsx | 6 +- .../promptbox/PromptBoxInternal.test.tsx | 19 +- .../promptbox/PromptBoxInternal.tsx | 15 +- .../promptbox/mentions/MentionMenu.tsx | 185 ++++++------------ .../promptbox/mentions/Mentions.stories.tsx | 6 +- .../embedded-chat/EmbeddedThreadChat.test.tsx | 13 +- .../embedded-chat/useComposerTypeahead.ts | 4 +- .../app/src/hooks/pluginMentionSuggestions.ts | 9 +- .../src/hooks/promptMentionCandidates.test.ts | 83 ++++++++ apps/app/src/hooks/promptMentionCandidates.ts | 164 ++++++++++++++++ apps/app/src/hooks/usePromptMentions.test.tsx | 2 +- .../usePromptMentions.thread-context.test.tsx | 2 +- apps/app/src/hooks/usePromptMentions.ts | 64 ++---- .../src/prompt/mentions/mention-candidates.ts | 48 +++-- .../client-core/src/prompt/mentions/types.ts | 5 +- .../test/mention-suggestion-order.test.ts | 8 +- 22 files changed, 420 insertions(+), 234 deletions(-) create mode 100644 apps/app/src/hooks/promptMentionCandidates.test.ts create mode 100644 apps/app/src/hooks/promptMentionCandidates.ts diff --git a/apps/app/.ladle/story-fixtures.ts b/apps/app/.ladle/story-fixtures.ts index 68258ebc56..45a842466b 100644 --- a/apps/app/.ladle/story-fixtures.ts +++ b/apps/app/.ladle/story-fixtures.ts @@ -12,6 +12,7 @@ import type { ProviderCliStatus, } from "@bb/host-daemon-contract"; import type { ProjectResponse } from "@bb/server-contract"; +import { EMPTY_ORDERED_MENTION_SUGGESTIONS } from "@bb/client-core"; import { getProviderIconInfo } from "../src/lib/provider-icon"; import type { PickerOption } from "../src/components/pickers/OptionPicker"; import type { ModelPickerOption } from "../src/components/pickers/model-picker-option"; @@ -72,7 +73,7 @@ export function makeTypeaheadConfig( commandOverrides: Partial = {}, ): TypeaheadConfig { const mention: TypeaheadMentionConfig = { - suggestions: [], + results: EMPTY_ORDERED_MENTION_SUGGESTIONS, isLoading: false, isError: false, onQueryChange: noop, diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx index 3419369d75..357e1afaaa 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx @@ -11,6 +11,7 @@ import { formatEnvironmentDisplay, type EnvironmentDisplayHostContext, } from "@bb/core-ui"; +import { EMPTY_ORDERED_MENTION_SUGGESTIONS } from "@bb/client-core"; import type { SystemExecutionOptionsModelLoadError, ThreadContextWindowUsage, @@ -314,7 +315,7 @@ const usage: ThreadContextWindowUsage = { const typeaheadBase: TypeaheadConfig = { mention: { - suggestions: [], + results: EMPTY_ORDERED_MENTION_SUGGESTIONS, isLoading: false, isError: false, onQueryChange: noop, diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx index b2ec85bebe..49768b9094 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.test.tsx @@ -11,6 +11,7 @@ import { import { Profiler, startTransition, type ReactNode } from "react"; import { flushSync } from "react-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EMPTY_ORDERED_MENTION_SUGGESTIONS } from "@bb/client-core"; import { resetPluginSlotStoreForTest, setPluginSlotRegistrations, @@ -243,7 +244,7 @@ function createFollowUpPromptBoxProps( }, typeahead: { mention: { - suggestions: [], + results: EMPTY_ORDERED_MENTION_SUGGESTIONS, isLoading: false, isError: false, onQueryChange: vi.fn(), diff --git a/apps/app/src/components/promptbox/NewThreadComposer.tsx b/apps/app/src/components/promptbox/NewThreadComposer.tsx index 597ea52dcd..6115991e36 100644 --- a/apps/app/src/components/promptbox/NewThreadComposer.tsx +++ b/apps/app/src/components/promptbox/NewThreadComposer.tsx @@ -1250,7 +1250,7 @@ export function NewThreadComposer({ typeahead={{ mention: { triggers: promptMentions.triggers, - suggestions: promptMentions.suggestions, + results: promptMentions.results, isLoading: promptMentions.isLoading, isError: promptMentions.isError, onQueryChange: promptMentions.setQuery, diff --git a/apps/app/src/components/promptbox/PromptBoxAppShortcuts.test.tsx b/apps/app/src/components/promptbox/PromptBoxAppShortcuts.test.tsx index b5ebc6675d..5e1ed66bc3 100644 --- a/apps/app/src/components/promptbox/PromptBoxAppShortcuts.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxAppShortcuts.test.tsx @@ -4,6 +4,7 @@ import { act, cleanup, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; import { defaultAppSettings, type AppShortcut } from "@bb/domain"; +import { EMPTY_ORDERED_MENTION_SUGGESTIONS } from "@bb/client-core"; import { AppCommandProvider, useAppCommandHandler, @@ -121,7 +122,7 @@ function renderComposer(extra: React.ReactNode = null) { mentionMenuPlacement="bottom" typeahead={{ mention: { - suggestions: [], + results: EMPTY_ORDERED_MENTION_SUGGESTIONS, isLoading: false, isError: false, onQueryChange: vi.fn(), diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx index f585b7fb27..bdfc5370e4 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx @@ -29,6 +29,7 @@ vi.hoisted(() => { }); import type { PromptTextMention } from "@bb/domain"; +import { EMPTY_ORDERED_MENTION_SUGGESTIONS } from "@bb/client-core"; import { useState } from "react"; import { act, @@ -97,7 +98,7 @@ describe("PromptBoxInternal on a real iPadOS ProseMirror build", () => { mentionMenuPlacement="bottom" typeahead={{ mention: { - suggestions: [], + results: EMPTY_ORDERED_MENTION_SUGGESTIONS, isLoading: false, isError: false, onQueryChange: vi.fn(), @@ -162,7 +163,7 @@ describe("PromptBoxInternal on a real iPadOS ProseMirror build", () => { mentionMenuPlacement="bottom" typeahead={{ mention: { - suggestions: [], + results: EMPTY_ORDERED_MENTION_SUGGESTIONS, isLoading: false, isError: false, onQueryChange: vi.fn(), @@ -201,7 +202,7 @@ describe("PromptBoxInternal on a real iPadOS ProseMirror build", () => { mentionMenuPlacement="bottom" typeahead={{ mention: { - suggestions: [], + results: EMPTY_ORDERED_MENTION_SUGGESTIONS, isLoading: false, isError: false, onQueryChange: vi.fn(), diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.stories.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.stories.tsx index b5c6c24f07..6415261e60 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.stories.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.stories.tsx @@ -23,6 +23,7 @@ import { makeExecutionControlsProps, makeTypeaheadConfig as makeTypeahead, } from "../../../.ladle/story-fixtures"; +import { orderPromptMentionSuggestions } from "@/hooks/promptMentionCandidates"; export default { title: "promptbox/Prompt Box Internal", @@ -846,7 +847,10 @@ function WithLiveMentionsRow() { onSubmit={noop} placeholder="Type @ to mention a file, folder, section, or thread" typeahead={makeTypeahead({ - suggestions, + results: orderPromptMentionSuggestions({ + query: query ?? "", + suggestions, + }), onQueryChange: setQuery, })} mentionMenuPlacement="bottom" diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index b6bf7a7564..75e09a7731 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -22,7 +22,10 @@ import { } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; -import { emptyPromptDraftState } from "@bb/client-core"; +import { + EMPTY_ORDERED_MENTION_SUGGESTIONS, + emptyPromptDraftState, +} from "@bb/client-core"; import { getComposerInputLock, useComposer, @@ -66,6 +69,7 @@ import { type TypeaheadConfig, } from "./PromptBoxInternal"; import { promptMentionClipboardContent } from "./mentions/prompt-mention-clipboard"; +import { orderPromptMentionSuggestions } from "@/hooks/promptMentionCandidates"; import type { PromptMentionSuggestion, ProviderCommandSuggestion, @@ -123,7 +127,7 @@ function createPromptBoxProps( mentionMenuPlacement: "bottom", typeahead: { mention: { - suggestions: [], + results: EMPTY_ORDERED_MENTION_SUGGESTIONS, isLoading: false, isError: false, onQueryChange: vi.fn(), @@ -142,7 +146,7 @@ function buildTypeaheadConfig({ onCommandQueryChange = () => {}, }: { mentionTriggers?: TypeaheadConfig["mention"]["triggers"]; - mentionSuggestions?: TypeaheadConfig["mention"]["suggestions"]; + mentionSuggestions?: readonly PromptMentionSuggestion[]; onMentionQueryChange?: TypeaheadConfig["mention"]["onQueryChange"]; commandSuggestions?: TypeaheadConfig["command"]["suggestions"]; onCommandQueryChange?: (query: string | null) => void; @@ -150,7 +154,10 @@ function buildTypeaheadConfig({ return { mention: { triggers: mentionTriggers, - suggestions: mentionSuggestions, + results: orderPromptMentionSuggestions({ + query: "", + suggestions: mentionSuggestions, + }), isLoading: false, isError: false, onQueryChange: onMentionQueryChange, @@ -269,7 +276,7 @@ function renderPromptBox( options: { initialMentionRanges?: PromptTextMention[]; mentionTriggers?: TypeaheadConfig["mention"]["triggers"]; - mentionSuggestions?: TypeaheadConfig["mention"]["suggestions"]; + mentionSuggestions?: readonly PromptMentionSuggestion[]; commandSuggestions?: TypeaheadConfig["command"]["suggestions"]; } = {}, ) { @@ -3747,7 +3754,7 @@ describe("PromptBoxInternal command typeahead submit", () => { onSubmit={onSubmit} typeahead={{ mention: { - suggestions: [], + results: EMPTY_ORDERED_MENTION_SUGGESTIONS, isLoading: false, isError: false, onQueryChange: () => {}, diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index fc81419366..68df8655f7 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -30,6 +30,7 @@ import { type CommandMenuState, type ComposerCommandSuggestion, type MentionMenuState, + type OrderedMentionSuggestions, type ProviderCommandSuggestion, type PromptMentionSuggestion, type TypeaheadMenuState, @@ -278,13 +279,13 @@ function PromptSubmitButton({ } /** - * The `@`-mention half of {@link TypeaheadConfig}. Unchanged from the prior - * `MentionsConfig` surface other than living under `typeahead.mention`. + * The `@`-mention half of {@link TypeaheadConfig}. `results` carries the + * canonical grouped render model plus the identical flat keyboard order. */ export interface TypeaheadMentionConfig { /** Mention trigger characters to watch. Defaults to `@`. */ triggers?: readonly PluginMentionTrigger[]; - suggestions: readonly PromptMentionSuggestion[]; + results: OrderedMentionSuggestions; isLoading: boolean; isError: boolean; /** Called whenever the active mention query changes; null when no mention is active. */ @@ -1235,7 +1236,7 @@ export function PromptBoxInternal({ } = submission; const { triggers: mentionTriggerChars = DEFAULT_TYPEAHEAD_MENTION_TRIGGERS, - suggestions: mentionSuggestions, + results: mentionResults, isLoading: mentionLoading, isError: mentionError, onQueryChange: onMentionQueryChange, @@ -2227,9 +2228,9 @@ export function PromptBoxInternal({ activeTriggerKind === "command" ? orderedCommandSuggestions : activeTriggerKind === "mention" - ? mentionSuggestions + ? mentionResults.suggestions : [], - [activeTriggerKind, mentionSuggestions, orderedCommandSuggestions], + [activeTriggerKind, mentionResults.suggestions, orderedCommandSuggestions], ); const activeMentionQuery = @@ -2241,7 +2242,7 @@ export function PromptBoxInternal({ ? { kind: "loading" } : mentionError ? { kind: "error" } - : { kind: "results", suggestions: mentionSuggestions }; + : { kind: "results", results: mentionResults }; const commandMenuState: CommandMenuState = commandLoading ? { kind: "loading" } diff --git a/apps/app/src/components/promptbox/mentions/MentionMenu.tsx b/apps/app/src/components/promptbox/mentions/MentionMenu.tsx index 36e6d5f5ab..594c12e850 100644 --- a/apps/app/src/components/promptbox/mentions/MentionMenu.tsx +++ b/apps/app/src/components/promptbox/mentions/MentionMenu.tsx @@ -21,10 +21,12 @@ import { PluginIcon } from "@/components/plugin/PluginIcon"; import { Icon } from "@bb/shared-ui/icon"; import { TruncateStart } from "@/components/ui/truncate-start.js"; import { cn } from "@bb/shared-ui/lib/utils"; -import type { - ComposerCommandSuggestion, - PromptMentionSuggestion, - TypeaheadMenuState, +import { + EMPTY_ORDERED_MENTION_SUGGESTIONS, + type ComposerCommandSuggestion, + type OrderedMentionSuggestions, + type PromptMentionSuggestion, + type TypeaheadMenuState, } from "@bb/client-core"; /** @@ -47,6 +49,22 @@ interface MentionMenuProps { onCommandLoadMore?: () => void; } +interface MentionResultsProps { + results: OrderedMentionSuggestions; + selectedIndex: number; + onApply: (item: TypeaheadSuggestion) => void; + onDismiss?: () => void; + itemRefs: React.MutableRefObject>; +} + +interface CommandResultsProps { + suggestions: readonly ComposerCommandSuggestion[]; + selectedIndex: number; + onApply: (item: TypeaheadSuggestion) => void; + onDismiss?: () => void; + itemRefs: React.MutableRefObject>; +} + interface MenuSectionItem { item: TItem; index: number; @@ -88,90 +106,11 @@ function groupSections(args: { return [...sectionsByKind.values()]; } -type PathMentionSectionKind = "workspace" | "thread-storage"; -// Plugin providers each get their own section, labeled by the provider -// (plugin design §4.9); the section kind embeds pluginId + providerId so -// identically-labeled providers from different plugins never merge. -type PluginMentionSectionKind = `plugin:${string}`; -type MentionSectionKind = - | "threads" - | "projects" - | "sections" - | PathMentionSectionKind - | PluginMentionSectionKind; type PathMentionSuggestion = Extract; type SecondaryContextKind = "path" | "project"; -function getPluginSectionKind( - item: Extract, -): PluginMentionSectionKind { - // Provider ids exclude ":" (enforced at registration), so this composite - // is unambiguous. - return `plugin:${item.pluginId}:${item.providerId}`; -} - -/** Display label per plugin section kind (first row wins per provider). */ -function getPluginSectionLabels( - suggestions: readonly PromptMentionSuggestion[], -): Map { - const labels = new Map(); - for (const item of suggestions) { - if (item.kind !== "plugin") continue; - const kind = getPluginSectionKind(item); - if (!labels.has(kind)) { - labels.set(kind, item.providerLabel); - } - } - return labels; -} - -function getMentionSectionKind( - item: PromptMentionSuggestion, -): MentionSectionKind { - if (item.kind === "thread") { - return "threads"; - } - if (item.kind === "project") { - return "projects"; - } - if (item.kind === "section") { - return "sections"; - } - if (item.kind === "plugin") { - return getPluginSectionKind(item); - } - return getPathSectionKind(item); -} - -function getPathSectionKind( - item: PathMentionSuggestion, -): PathMentionSectionKind { - return item.source === "thread-storage" ? "thread-storage" : "workspace"; -} - -function getMentionSectionLabel( - kind: MentionSectionKind, - pluginSectionLabels: ReadonlyMap, -): string { - if (kind === "threads") { - return "Threads"; - } - if (kind === "projects") { - return "Projects"; - } - if (kind === "sections") { - return "Sections"; - } - if (kind === "workspace" || kind === "thread-storage") { - return getPathSectionLabel(kind); - } - // Plugin sections display the provider's label; the kind itself is the - // pluginId + providerId identity, never shown. - return pluginSectionLabels.get(kind) ?? kind.slice("plugin:".length); -} - -function getPathSectionLabel(kind: PathMentionSectionKind): string { - if (kind === "thread-storage") { +function getPathSectionLabel(item: PathMentionSuggestion): string { + if (item.source === "thread-storage") { return "Thread storage"; } return "Workspace"; @@ -195,7 +134,7 @@ function getMentionTitle(item: PromptMentionSuggestion): string { return `${item.providerLabel}: ${item.title}`; } - return `${getPathSectionLabel(getPathSectionKind(item))}: ${item.path}`; + return `${getPathSectionLabel(item)}: ${item.path}`; } function getMentionKey(item: PromptMentionSuggestion, index: number): string { @@ -415,28 +354,13 @@ function MenuSectionHeader({ } function MentionResults({ - suggestions, + results, selectedIndex, onApply, onDismiss, itemRefs, -}: { - suggestions: readonly PromptMentionSuggestion[]; - selectedIndex: number; - onApply: (item: TypeaheadSuggestion) => void; - onDismiss?: () => void; - itemRefs: React.MutableRefObject>; -}) { - const sections = useMemo(() => { - const pluginSectionLabels = getPluginSectionLabels(suggestions); - return groupSections({ - suggestions, - sectionKind: getMentionSectionKind, - sectionLabel: (kind) => getMentionSectionLabel(kind, pluginSectionLabels), - }); - }, [suggestions]); - - if (sections.length === 0) { +}: MentionResultsProps) { + if (results.groups.length === 0) { return ( No matching mentions @@ -446,14 +370,15 @@ function MentionResults({ return (
- {sections.map((section, sectionIndex) => ( -
+ {results.groups.map((group, groupIndex) => ( +
- {section.items.map(({ item, index }) => { + {group.suggestions.map((item, itemIndex) => { + const index = group.startIndex + itemIndex; let primary: string; let secondaryContext: string | null = null; let secondaryContextKind: SecondaryContextKind | null = null; @@ -514,13 +439,7 @@ function CommandResults({ onApply, onDismiss, itemRefs, -}: { - suggestions: readonly ComposerCommandSuggestion[]; - selectedIndex: number; - onApply: (item: TypeaheadSuggestion) => void; - onDismiss?: () => void; - itemRefs: React.MutableRefObject>; -}) { +}: CommandResultsProps) { const sections = useMemo( () => groupSections({ @@ -580,6 +499,29 @@ function CommandResults({ ); } +function typeaheadResultsLength(state: TypeaheadMenuState): number { + if (state.trigger === "mention") { + return state.state.kind === "results" + ? state.state.results.suggestions.length + : 0; + } + return state.state.kind === "results" ? state.state.suggestions.length : 0; +} + +function mentionResults(state: TypeaheadMenuState): OrderedMentionSuggestions { + return state.trigger === "mention" && state.state.kind === "results" + ? state.state.results + : EMPTY_ORDERED_MENTION_SUGGESTIONS; +} + +function commandSuggestions( + state: TypeaheadMenuState, +): readonly ComposerCommandSuggestion[] { + return state.trigger === "command" && state.state.kind === "results" + ? state.state.suggestions + : []; +} + export function MentionMenu({ state, selectedIndex, @@ -609,8 +551,7 @@ export function MentionMenu({ ); const innerState = state.state; - const resultsLength = - innerState.kind === "results" ? innerState.suggestions.length : 0; + const resultsLength = typeaheadResultsLength(state); // Trim refs when the result list shortens so stale entries don't survive. useEffect(() => { @@ -655,9 +596,7 @@ export function MentionMenu({ ) : state.trigger === "command" ? ( ) : ( ({ })); vi.mock("@/components/promptbox/FollowUpPromptBox", async () => { - const { usePluginComposerHostDraft } = await import( - "@/components/plugin/plugin-composer-host" - ); + const { usePluginComposerHostDraft } = + await import("@/components/plugin/plugin-composer-host"); // A host-draft subscriber, like plugin surfaces reading useComposerView(). - function BottomHostDraftProbe({ - host, - }: { - host: PluginComposerHost | null; - }) { + function BottomHostDraftProbe({ host }: { host: PluginComposerHost | null }) { // Record what the CURRENT host's getCurrent() returns at the exact moment // subscribeDraft notifies. useSyncExternalStore reads the snapshot inside // the notification to decide whether to re-render, so a notify that fires @@ -201,7 +196,7 @@ vi.mock("@/hooks/useThreadCreationOptions", () => ({ vi.mock("@/hooks/usePromptMentions", () => ({ usePromptMentions: () => ({ triggers: [], - suggestions: [], + results: { groups: [], suggestions: [] }, isLoading: false, isError: false, setQuery: vi.fn(), diff --git a/apps/app/src/components/thread/embedded-chat/useComposerTypeahead.ts b/apps/app/src/components/thread/embedded-chat/useComposerTypeahead.ts index 6efb340392..51ae64870c 100644 --- a/apps/app/src/components/thread/embedded-chat/useComposerTypeahead.ts +++ b/apps/app/src/components/thread/embedded-chat/useComposerTypeahead.ts @@ -74,7 +74,7 @@ export function useComposerTypeahead({ () => ({ mention: { triggers: promptMentions.triggers, - suggestions: promptMentions.suggestions, + results: promptMentions.results, isLoading: promptMentions.isLoading, isError: promptMentions.isError, onQueryChange: promptMentions.setQuery, @@ -104,7 +104,7 @@ export function useComposerTypeahead({ promptMentions.isError, promptMentions.isLoading, promptMentions.setQuery, - promptMentions.suggestions, + promptMentions.results, promptMentions.triggers, resolveMentionLink, ], diff --git a/apps/app/src/hooks/pluginMentionSuggestions.ts b/apps/app/src/hooks/pluginMentionSuggestions.ts index dad160e17f..7a5a78713f 100644 --- a/apps/app/src/hooks/pluginMentionSuggestions.ts +++ b/apps/app/src/hooks/pluginMentionSuggestions.ts @@ -1,6 +1,11 @@ import type { PluginMentionSearchGroup } from "./queries/plugin-contribution-queries"; import type { PromptMentionSuggestion } from "@bb/client-core"; +type PluginMentionSuggestion = Extract< + PromptMentionSuggestion, + { kind: "plugin" } +>; + /** * Map GET /plugins/mentions/search groups onto mention-menu suggestions * (plugin design §4.9). Group order is server-owned (plugin id, then @@ -10,8 +15,8 @@ import type { PromptMentionSuggestion } from "@bb/client-core"; */ export function buildPluginMentionSuggestions( groups: readonly PluginMentionSearchGroup[], -): PromptMentionSuggestion[] { - const suggestions: PromptMentionSuggestion[] = []; +): PluginMentionSuggestion[] { + const suggestions: PluginMentionSuggestion[] = []; for (const group of groups) { for (const item of group.items) { const title = item.title.trim(); diff --git a/apps/app/src/hooks/promptMentionCandidates.test.ts b/apps/app/src/hooks/promptMentionCandidates.test.ts new file mode 100644 index 0000000000..85161a965a --- /dev/null +++ b/apps/app/src/hooks/promptMentionCandidates.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import type { PromptMentionSuggestion } from "@bb/client-core"; +import { buildPromptMentionResults } from "./promptMentionCandidates"; + +type ProjectMentionSuggestion = Extract< + PromptMentionSuggestion, + { kind: "project" } +>; +type PluginMentionSuggestion = Extract< + PromptMentionSuggestion, + { kind: "plugin" } +>; + +function project(name: string): ProjectMentionSuggestion { + return { + kind: "project", + path: "project:proj_automations", + replacement: "project:proj_automations", + projectId: "proj_automations", + name, + }; +} + +function plugin( + title: string, + searchAliases: readonly string[], +): PluginMentionSuggestion { + return { + kind: "plugin", + pluginId: "at-plugin", + providerId: "installed", + itemId: "installed:automations", + providerLabel: "Installed", + title, + searchAliases, + subtitle: "Automation tools", + icon: null, + replacement: title, + }; +} + +describe("buildPromptMentionResults", () => { + it("ranks a source identity alias ahead of a weaker built-in title", () => { + const results = buildPromptMentionResults({ + query: "automations", + paths: [], + threads: [], + projects: [project("Automations project")], + sections: [], + plugins: [plugin("Workflow Tools", ["automations"])], + }); + + expect(results.groups.map((group) => group.label)).toEqual([ + "Installed", + "Projects", + ]); + expect( + results.suggestions.map((suggestion) => suggestion.replacement), + ).toEqual(["Workflow Tools", "project:proj_automations"]); + }); + + it("keeps provider sections distinct when their visible labels collide", () => { + const first = plugin("First", ["first"]); + const second: PluginMentionSuggestion = { + ...plugin("Second", ["second"]), + pluginId: "other-plugin", + itemId: "installed:second", + }; + const results = buildPromptMentionResults({ + query: "", + paths: [], + threads: [], + projects: [], + sections: [], + plugins: [first, second], + }); + + expect(results.groups.map((group) => group.key)).toEqual([ + "plugin:at-plugin:installed", + "plugin:other-plugin:installed", + ]); + }); +}); diff --git a/apps/app/src/hooks/promptMentionCandidates.ts b/apps/app/src/hooks/promptMentionCandidates.ts new file mode 100644 index 0000000000..fa9e5482b4 --- /dev/null +++ b/apps/app/src/hooks/promptMentionCandidates.ts @@ -0,0 +1,164 @@ +import { + orderMentionCandidates, + type MentionCandidate, + type OrderedMentionSuggestions, + type PromptMentionSuggestion, +} from "@bb/client-core"; + +type ThreadMentionSuggestion = Extract< + PromptMentionSuggestion, + { kind: "thread" } +>; +type ProjectMentionSuggestion = Extract< + PromptMentionSuggestion, + { kind: "project" } +>; +type SectionMentionSuggestion = Extract< + PromptMentionSuggestion, + { kind: "section" } +>; +type PathMentionSuggestion = Extract; +type PluginMentionSuggestion = Extract< + PromptMentionSuggestion, + { kind: "plugin" } +>; + +interface BuildPromptMentionResultsArgs { + query: string; + paths: readonly PathMentionSuggestion[]; + threads: readonly ThreadMentionSuggestion[]; + projects: readonly ProjectMentionSuggestion[]; + sections: readonly SectionMentionSuggestion[]; + plugins: readonly PluginMentionSuggestion[]; +} + +interface OrderPromptMentionSuggestionsArgs { + query: string; + suggestions: readonly PromptMentionSuggestion[]; +} + +function threadMentionCandidate( + suggestion: ThreadMentionSuggestion, +): MentionCandidate { + return { + suggestion, + visibleTitle: suggestion.title?.trim() || suggestion.threadId, + identityTerms: [suggestion.threadId], + supportingTerms: + suggestion.projectName === undefined ? [] : [suggestion.projectName], + groupKey: "threads", + groupLabel: "Threads", + }; +} + +function projectMentionCandidate( + suggestion: ProjectMentionSuggestion, +): MentionCandidate { + return { + suggestion, + visibleTitle: suggestion.name, + identityTerms: [suggestion.projectId], + supportingTerms: [], + groupKey: "projects", + groupLabel: "Projects", + }; +} + +function sectionMentionCandidate( + suggestion: SectionMentionSuggestion, +): MentionCandidate { + return { + suggestion, + visibleTitle: suggestion.name, + identityTerms: [suggestion.sectionId], + supportingTerms: [], + groupKey: "sections", + groupLabel: "Sections", + }; +} + +function pathMentionCandidate( + suggestion: PathMentionSuggestion, +): MentionCandidate { + const isThreadStorage = suggestion.source === "thread-storage"; + return { + suggestion, + visibleTitle: suggestion.name, + identityTerms: [suggestion.path, suggestion.replacement], + supportingTerms: [], + groupKey: `path:${suggestion.source}`, + groupLabel: isThreadStorage ? "Thread storage" : "Workspace", + }; +} + +function pluginMentionCandidate( + suggestion: PluginMentionSuggestion, +): MentionCandidate { + return { + suggestion, + visibleTitle: suggestion.title, + identityTerms: suggestion.searchAliases, + supportingTerms: suggestion.subtitle === null ? [] : [suggestion.subtitle], + groupKey: `plugin:${suggestion.pluginId}:${suggestion.providerId}`, + groupLabel: suggestion.providerLabel, + }; +} + +function promptMentionCandidate( + suggestion: PromptMentionSuggestion, +): MentionCandidate { + if (suggestion.kind === "thread") { + return threadMentionCandidate(suggestion); + } + if (suggestion.kind === "project") { + return projectMentionCandidate(suggestion); + } + if (suggestion.kind === "section") { + return sectionMentionCandidate(suggestion); + } + if (suggestion.kind === "plugin") { + return pluginMentionCandidate(suggestion); + } + return pathMentionCandidate(suggestion); +} + +export function orderPromptMentionSuggestions( + args: OrderPromptMentionSuggestionsArgs, +): OrderedMentionSuggestions { + return orderMentionCandidates( + args.suggestions.map(promptMentionCandidate), + args.query, + ); +} + +/** + * Normalize every mention source before applying the shared cross-resource + * relevance policy. The returned groups are the only section model rendered + * by the menu, and `suggestions` is the identical keyboard-navigation order. + */ +export function buildPromptMentionResults( + args: BuildPromptMentionResultsArgs, +): OrderedMentionSuggestions { + const sourceOrdered: readonly PromptMentionSuggestion[] = args.query + .trim() + .includes("/") + ? [ + ...args.paths, + ...args.threads, + ...args.projects, + ...args.sections, + ...args.plugins, + ] + : [ + ...args.threads, + ...args.projects, + ...args.sections, + ...args.paths, + ...args.plugins, + ]; + + return orderPromptMentionSuggestions({ + query: args.query, + suggestions: sourceOrdered, + }); +} diff --git a/apps/app/src/hooks/usePromptMentions.test.tsx b/apps/app/src/hooks/usePromptMentions.test.tsx index e2d518bdda..d950e6f0c4 100644 --- a/apps/app/src/hooks/usePromptMentions.test.tsx +++ b/apps/app/src/hooks/usePromptMentions.test.tsx @@ -111,7 +111,7 @@ describe("usePromptMentions", () => { }); await waitFor(() => { expect(result.current.isLoading).toBe(false); - expect(result.current.suggestions).toHaveLength(1); + expect(result.current.results.suggestions).toHaveLength(1); }); }); }); diff --git a/apps/app/src/hooks/usePromptMentions.thread-context.test.tsx b/apps/app/src/hooks/usePromptMentions.thread-context.test.tsx index 52699b9d2b..5563cc69c9 100644 --- a/apps/app/src/hooks/usePromptMentions.thread-context.test.tsx +++ b/apps/app/src/hooks/usePromptMentions.thread-context.test.tsx @@ -93,7 +93,7 @@ describe("usePromptMentions thread contexts", () => { result.current.setQuery("Only worktree", "@"); }); - expect(result.current.suggestions).toEqual([ + expect(result.current.results.suggestions).toEqual([ expect.objectContaining({ kind: "thread", threadId: "thr_existing", diff --git a/apps/app/src/hooks/usePromptMentions.ts b/apps/app/src/hooks/usePromptMentions.ts index b79b6258c5..1e3d519bce 100644 --- a/apps/app/src/hooks/usePromptMentions.ts +++ b/apps/app/src/hooks/usePromptMentions.ts @@ -22,15 +22,13 @@ import { usePathSuggestions, PATH_SUGGESTION_DEBOUNCE_MS, } from "./usePathSuggestions"; -import { - orderMentionSuggestions, - type PromptMentionSuggestion, -} from "@bb/client-core"; import { DEFAULT_PLUGIN_MENTION_TRIGGER, PLUGIN_MENTION_TRIGGER_VALUES, + type OrderedMentionSuggestions, type PluginMentionTrigger, } from "@bb/client-core"; +import { buildPromptMentionResults } from "./promptMentionCandidates"; const PROMPT_MENTION_SOURCE_LIMIT = 8; @@ -50,43 +48,11 @@ interface UsePromptMentionsResult { query: string | null, trigger: PluginMentionTrigger | null, ) => void; - suggestions: PromptMentionSuggestion[]; + results: OrderedMentionSuggestions; isLoading: boolean; isError: boolean; } -interface BuildPromptMentionSuggestionsArgs { - pathSuggestions: readonly PromptMentionSuggestion[]; - threadSuggestions: readonly PromptMentionSuggestion[]; - projectSuggestions: readonly PromptMentionSuggestion[]; - sectionSuggestions: readonly PromptMentionSuggestion[]; - pluginSuggestions: readonly PromptMentionSuggestion[]; - trimmedQuery: string; -} - -function buildPromptMentionSuggestions( - args: BuildPromptMentionSuggestionsArgs, -): PromptMentionSuggestion[] { - // A query containing "/" reads as a file path, so paths win relevance ties; - // otherwise the existing named-entity order remains the fallback. - const sourceOrdered = args.trimmedQuery.includes("/") - ? [ - ...args.pathSuggestions, - ...args.threadSuggestions, - ...args.projectSuggestions, - ...args.sectionSuggestions, - ...args.pluginSuggestions, - ] - : [ - ...args.threadSuggestions, - ...args.projectSuggestions, - ...args.sectionSuggestions, - ...args.pathSuggestions, - ...args.pluginSuggestions, - ]; - return orderMentionSuggestions(sourceOrdered, args.trimmedQuery); -} - function buildProjectNamesById( sidebarNavigation: SidebarBootstrapResponse | undefined, ): ReadonlyMap { @@ -282,18 +248,16 @@ export function usePromptMentions( : [], [hasMentionProviders, pluginSearch.data, pluginSearchMatchesInput], ); - const suggestions = useMemo( + const results = useMemo( () => - hasQuery - ? buildPromptMentionSuggestions({ - pathSuggestions, - threadSuggestions, - projectSuggestions, - sectionSuggestions, - pluginSuggestions, - trimmedQuery, - }) - : [], + buildPromptMentionResults({ + query: hasQuery ? trimmedQuery : "", + paths: hasQuery ? pathSuggestions : [], + threads: hasQuery ? threadSuggestions : [], + projects: hasQuery ? projectSuggestions : [], + sections: hasQuery ? sectionSuggestions : [], + plugins: hasQuery ? pluginSuggestions : [], + }), [ hasQuery, pathSuggestions, @@ -311,7 +275,7 @@ export function usePromptMentions( // to the loading state mid-typing. const isLoading = hasQuery && - suggestions.length === 0 && + results.suggestions.length === 0 && ((includeBuiltInSources && (pathSearch.isDebouncing || pathSearch.isLoading || @@ -337,7 +301,7 @@ export function usePromptMentions( query, triggers: mentionTriggers, setQuery, - suggestions, + results, isLoading, isError, }; diff --git a/packages/client-core/src/prompt/mentions/mention-candidates.ts b/packages/client-core/src/prompt/mentions/mention-candidates.ts index 196814e6bc..260a4004fa 100644 --- a/packages/client-core/src/prompt/mentions/mention-candidates.ts +++ b/packages/client-core/src/prompt/mentions/mention-candidates.ts @@ -6,20 +6,22 @@ import type { PromptMentionSuggestion } from "./types.js"; * group. Client core owns all relevance decisions after that boundary. */ export interface MentionCandidate { - suggestion: PromptMentionSuggestion; - visibleTitle: string; + readonly suggestion: PromptMentionSuggestion; + readonly visibleTitle: string; /** Additional identities, such as a resource id or provider search alias. */ - identityTerms: readonly string[]; - supportingTerms: readonly string[]; - groupKey: string; - groupLabel: string; + readonly identityTerms: readonly string[]; + readonly supportingTerms: readonly string[]; + readonly groupKey: string; + readonly groupLabel: string; } /** One intact rendered group after relevance ordering. */ export interface OrderedMentionSuggestionGroup { - key: string; - label: string; - suggestions: readonly PromptMentionSuggestion[]; + readonly key: string; + readonly label: string; + /** Index of this group's first row in the flattened navigation sequence. */ + readonly startIndex: number; + readonly suggestions: readonly PromptMentionSuggestion[]; } /** @@ -27,10 +29,15 @@ export interface OrderedMentionSuggestionGroup { * navigation. `suggestions` is the concatenation of `groups` in order. */ export interface OrderedMentionSuggestions { - groups: readonly OrderedMentionSuggestionGroup[]; - suggestions: readonly PromptMentionSuggestion[]; + readonly groups: readonly OrderedMentionSuggestionGroup[]; + readonly suggestions: readonly PromptMentionSuggestion[]; } +export const EMPTY_ORDERED_MENTION_SUGGESTIONS: OrderedMentionSuggestions = { + groups: [], + suggestions: [], +}; + interface RankedMentionCandidate { candidate: MentionCandidate; inputIndex: number; @@ -125,15 +132,22 @@ export function orderMentionCandidates( }); } + let nextStartIndex = 0; const groups = [...groupsByKey.values()] .sort(compareRankedMentionCandidateGroups) - .map((group) => ({ - key: group.key, - label: group.label, - suggestions: group.candidates + .map((group) => { + const suggestions = group.candidates .sort(compareRankedMentionCandidates) - .map(({ candidate }) => candidate.suggestion), - })); + .map(({ candidate }) => candidate.suggestion); + const orderedGroup: OrderedMentionSuggestionGroup = { + key: group.key, + label: group.label, + startIndex: nextStartIndex, + suggestions, + }; + nextStartIndex += suggestions.length; + return orderedGroup; + }); const suggestions = groups.flatMap((group) => group.suggestions); return { groups, suggestions }; diff --git a/packages/client-core/src/prompt/mentions/types.ts b/packages/client-core/src/prompt/mentions/types.ts index f0389d5857..ed2605ed8b 100644 --- a/packages/client-core/src/prompt/mentions/types.ts +++ b/packages/client-core/src/prompt/mentions/types.ts @@ -8,6 +8,7 @@ import { } from "@bb/server-contract"; import type { PromptMentionCommandTrigger } from "@bb/domain"; import type { PluginMentionTrigger } from "./plugin-mention-triggers.js"; +import type { OrderedMentionSuggestions } from "./mention-candidates.js"; type PromptPathMentionSource = "workspace" | "thread-storage"; type PromptPathMentionEntryKind = "file" | "directory"; @@ -252,7 +253,7 @@ export type ActiveTrigger = * Mutually-exclusive states the mention menu can render. Replaces the prior * 4-boolean flag soup (showQueryHint / mentionLoading / mentionError / * mentionSuggestions). The "results" state's empty-vs-populated rendering is - * a single decision inside the menu (`suggestions.length === 0` shows the + * a single decision inside the menu (`results.suggestions.length === 0` shows the * empty state). */ export type MentionMenuState = @@ -265,7 +266,7 @@ export type MentionMenuState = /** Suggestions resolved (possibly empty). */ | { kind: "results"; - suggestions: readonly PromptMentionSuggestion[]; + results: OrderedMentionSuggestions; }; /** diff --git a/packages/client-core/test/mention-suggestion-order.test.ts b/packages/client-core/test/mention-suggestion-order.test.ts index 039651f9de..7944808561 100644 --- a/packages/client-core/test/mention-suggestion-order.test.ts +++ b/packages/client-core/test/mention-suggestion-order.test.ts @@ -160,9 +160,11 @@ describe("orderMentionCandidates", () => { "plugin", ); - expect(results.groups.map((group) => [group.key, group.label])).toEqual([ - ["first", "First label"], - ["second", "Second label"], + expect( + results.groups.map((group) => [group.key, group.label, group.startIndex]), + ).toEqual([ + ["first", "First label", 0], + ["second", "Second label", 2], ]); expect(suggestionNames(results)).toEqual( results.groups From 7962d2c39746920680ec4202f938f13ac2d2aa10 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:55:38 +0000 Subject: [PATCH 06/10] Bump plugin SDK for mention aliases --- packages/domain/src/plugin-sdk-version.ts | 2 +- packages/plugin-sdk/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index 775519ae75..86b564dc58 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -16,7 +16,7 @@ // PLUGIN_SDK_MAJOR is 0, so the major-only artifact gate cannot distinguish // 0.x releases and is intentionally vacuous for them until a future 1.0. // Rebuildable artifacts still rebuild on the exact sdkVersion-differs trigger. -export const PLUGIN_SDK_VERSION = "0.4.24"; +export const PLUGIN_SDK_VERSION = "0.4.25"; /** Major of {@link PLUGIN_SDK_VERSION} — the plugin API compatibility number. */ export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 8db11f4609..76af3811ea 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.24", + "version": "0.4.25", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" From 78d480c8015951f424365873b71d448a7d793651 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:21:26 +0000 Subject: [PATCH 07/10] Sync mention alias SDK inventory --- packages/plugin-api-map/sdk-public-api.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-api-map/sdk-public-api.json b/packages/plugin-api-map/sdk-public-api.json index a506cd20af..c1a1589693 100644 --- a/packages/plugin-api-map/sdk-public-api.json +++ b/packages/plugin-api-map/sdk-public-api.json @@ -3,7 +3,7 @@ "entries": { ".": { "types": "bundled-types/bb-plugin-sdk.d.ts", - "sha256": "7994e5e3cc8a3f743d1098cb09334201ae359aac5148bd945fb1ac429908c80f" + "sha256": "a4fdf5f5dc1653d33babeef0739fddacea2c003c285d3ab2ef98d2127e34f651" }, "./ai-services": { "types": "bundled-types/bb-plugin-sdk-ai-services.d.ts", From 0aaf2be394addf9033c502acf89c64911a07191f Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 28 Aug 2026 01:47:08 -0700 Subject: [PATCH 08/10] Remove stale mention ranking comment --- apps/app/src/hooks/promptMentionCandidates.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/app/src/hooks/promptMentionCandidates.ts b/apps/app/src/hooks/promptMentionCandidates.ts index e66f1a65f7..29cb34bd3e 100644 --- a/apps/app/src/hooks/promptMentionCandidates.ts +++ b/apps/app/src/hooks/promptMentionCandidates.ts @@ -131,11 +131,6 @@ export function orderPromptMentionSuggestions( ); } -/** - * Normalize every mention source before applying the shared cross-resource - * relevance policy. The returned groups are the only section model rendered - * by the menu, and `suggestions` is the identical keyboard-navigation order. - */ export function buildPromptMentionResults( args: BuildPromptMentionResultsArgs, ): OrderedMentionSuggestions { From db3f69a0c2407d7298194525412da2f87cc59a6b Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 28 Aug 2026 17:44:41 -0700 Subject: [PATCH 09/10] Preserve typeahead selection across reranking --- .../promptbox/PromptBoxInternal.test.tsx | 102 ++++++++++++++++++ .../promptbox/PromptBoxInternal.tsx | 58 ++++++---- .../promptbox/mentions/MentionMenu.tsx | 46 ++++++-- 3 files changed, 177 insertions(+), 29 deletions(-) diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 262b3ef4ca..90b212c238 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -3113,6 +3113,108 @@ describe("PromptBoxInternal mention triggers", () => { expect(threadButton.className).toContain("bg-state-active"), ); }); + + it("keeps the keyboard-selected mention when a stronger delayed result arrives", async () => { + const threadSuggestion: PromptMentionSuggestion = { + kind: "thread", + path: "thread:thr_atlas", + replacement: "Atlas launch notes", + projectId: "proj_atlas", + projectName: "Atlas", + threadId: "thr_atlas", + title: "Atlas launch notes", + }; + const sectionSuggestion: PromptMentionSuggestion = { + kind: "section", + path: "section:sec_atlas_planning", + replacement: "Atlas planning", + sectionId: "sec_atlas_planning", + name: "Atlas planning", + }; + const delayedExactSuggestion: PromptMentionSuggestion = { + kind: "plugin", + pluginId: "installed", + providerId: "plugins", + itemId: "plugins:atlas", + providerLabel: "Installed", + title: "Atlas", + subtitle: null, + icon: null, + replacement: "Atlas", + }; + const changes: PromptChange[] = []; + const promptBoxRef = createRef(); + + function Harness({ + mentionSuggestions, + }: { + mentionSuggestions: readonly PromptMentionSuggestion[]; + }) { + const [value, setValue] = useState("@atlas"); + const [mentionRanges, setMentionRanges] = useState( + [], + ); + return ( + { + changes.push({ mentions: nextMentions, value: nextValue }); + setValue(nextValue); + setMentionRanges(nextMentions); + }} + onSubmit={vi.fn()} + typeahead={{ + mention: { + results: orderPromptMentionSuggestions({ + query: "atlas", + suggestions: mentionSuggestions, + }), + isLoading: false, + isError: false, + onQueryChange: vi.fn(), + }, + command: INERT_TYPEAHEAD_COMMAND_CONFIG, + }} + mentionMenuPlacement="bottom" + promptBoxRef={promptBoxRef} + /> + ); + } + + const initialSuggestions = [threadSuggestion, sectionSuggestion]; + const view = render(); + await focusPromptEnd(promptBoxRef); + + const sectionButton = await screen.findByRole("button", { + name: "Section: Atlas planning", + }); + fireEvent.keyDown(getPromptEditorElement(), { key: "ArrowDown" }); + await waitFor(() => + expect(sectionButton.className).toContain("bg-state-active"), + ); + + view.rerender( + , + ); + await screen.findByRole("button", { name: "Installed: Atlas" }); + await waitFor(() => + expect(sectionButton.className).toContain("bg-state-active"), + ); + + fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); + + await waitFor(() => expect(latestValue(changes)).toBe("@Atlas planning ")); + expect(latestChange(changes)?.mentions[0]?.resource).toMatchObject({ + kind: "section", + sectionId: "sec_atlas_planning", + }); + }); }); describe("PromptBoxInternal selection reveal", () => { diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 53ced0e3bb..940d37723f 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -119,7 +119,11 @@ import { import { exitHeading } from "./editor/prompt-editor-heading"; import { applyPromptListNewline } from "./editor/prompt-editor-list"; import { applyPromptParagraphNewline } from "./editor/prompt-editor-paragraph"; -import { MentionMenu, type TypeaheadSuggestion } from "./mentions/MentionMenu"; +import { + MentionMenu, + typeaheadSuggestionKey, + type TypeaheadSuggestion, +} from "./mentions/MentionMenu"; import { parsePromptMentionClipboardElement } from "./mentions/prompt-mention-clipboard"; import { ComposerEditorSlot } from "./ComposerEditorSlot"; import { QueuedEditorTypeaheadLayoutContext } from "./queued-editor-typeahead-layout"; @@ -1209,7 +1213,9 @@ export function PromptBoxInternal({ const [activeTrigger, setActiveTrigger] = useState( null, ); - const [selectedIndex, setSelectedIndex] = useState(0); + const [selectedSuggestionKey, setSelectedSuggestionKey] = useState< + string | null + >(null); const [expandedImageIndex, setExpandedImageIndex] = useState( null, ); @@ -1545,7 +1551,7 @@ export function PromptBoxInternal({ : ""; if (nextKey !== triggerKeyRef.current) { triggerKeyRef.current = nextKey; - setSelectedIndex(0); + setSelectedSuggestionKey(null); } setActiveTrigger(nextTrigger); @@ -2042,6 +2048,14 @@ export function PromptBoxInternal({ : [], [activeTriggerKind, mentionResults.suggestions, orderedCommandSuggestions], ); + const selectedSuggestionIndex = useMemo(() => { + if (selectedSuggestionKey === null) return -1; + return activeSuggestions.findIndex( + (suggestion) => + typeaheadSuggestionKey(suggestion) === selectedSuggestionKey, + ); + }, [activeSuggestions, selectedSuggestionKey]); + const selectedIndex = Math.max(0, selectedSuggestionIndex); const activeMentionQuery = activeTrigger?.kind === "mention" ? activeTrigger.query.trim() : ""; @@ -2107,14 +2121,13 @@ export function PromptBoxInternal({ }, [reportQueuedEditorTypeaheadLayout, showTypeaheadMenu]); useEffect(() => { - if (activeSuggestions.length === 0) { - setSelectedIndex(0); - return; - } - if (selectedIndex >= activeSuggestions.length) { - setSelectedIndex(0); + if ( + selectedSuggestionKey !== null && + selectedSuggestionIndex === -1 + ) { + setSelectedSuggestionKey(null); } - }, [activeSuggestions.length, selectedIndex]); + }, [selectedSuggestionIndex, selectedSuggestionKey]); useEffect(() => { if ( @@ -2181,7 +2194,7 @@ export function PromptBoxInternal({ }; isRestoringAppliedMentionRef.current = true; setActiveTrigger(null); - setSelectedIndex(0); + setSelectedSuggestionKey(null); onMentionQueryChange(null, null); try { @@ -2237,7 +2250,7 @@ export function PromptBoxInternal({ }; isRestoringAppliedMentionRef.current = true; setActiveTrigger(null); - setSelectedIndex(0); + setSelectedSuggestionKey(null); onCommandQueryChange(null); try { @@ -2421,7 +2434,7 @@ export function PromptBoxInternal({ dismissedTriggerRef.current = null; isRestoringAppliedMentionRef.current = true; setActiveTrigger(null); - setSelectedIndex(0); + setSelectedSuggestionKey(null); onCommandQueryChange(null); try { @@ -2455,7 +2468,7 @@ export function PromptBoxInternal({ triggerKeyRef.current = ""; dismissedTriggerRef.current = null; - setSelectedIndex(0); + setSelectedSuggestionKey(null); currentEditor .chain() .focus() @@ -2723,7 +2736,11 @@ export function PromptBoxInternal({ } return true; } - setSelectedIndex((prev) => (prev + 1) % activeSuggestions.length); + const nextIndex = (selectedIndex + 1) % activeSuggestions.length; + const nextSuggestion = activeSuggestions[nextIndex]; + if (nextSuggestion) { + setSelectedSuggestionKey(typeaheadSuggestionKey(nextSuggestion)); + } return true; } if ( @@ -2732,10 +2749,13 @@ export function PromptBoxInternal({ activeSuggestions.length > 0 ) { event.preventDefault(); - setSelectedIndex( - (prev) => - (prev + activeSuggestions.length - 1) % activeSuggestions.length, - ); + const nextIndex = + (selectedIndex + activeSuggestions.length - 1) % + activeSuggestions.length; + const nextSuggestion = activeSuggestions[nextIndex]; + if (nextSuggestion) { + setSelectedSuggestionKey(typeaheadSuggestionKey(nextSuggestion)); + } return true; } if ( diff --git a/apps/app/src/components/promptbox/mentions/MentionMenu.tsx b/apps/app/src/components/promptbox/mentions/MentionMenu.tsx index 38dc38bbe4..2a4f00456d 100644 --- a/apps/app/src/components/promptbox/mentions/MentionMenu.tsx +++ b/apps/app/src/components/promptbox/mentions/MentionMenu.tsx @@ -123,14 +123,30 @@ function getMentionTitle(item: PromptMentionSuggestion): string { return `${getPathSectionLabel(item)}: ${item.path}`; } -function getMentionKey(item: PromptMentionSuggestion, index: number): string { +function getMentionKey(item: PromptMentionSuggestion): string { if (item.kind === "path") { - return `${item.kind}-${item.source}-${item.entryKind}-${item.path}-${index}`; + return JSON.stringify([ + item.kind, + item.source, + item.entryKind, + item.path, + ]); } if (item.kind === "plugin") { - return `${item.kind}-${item.pluginId}-${item.itemId}-${index}`; + return JSON.stringify([ + item.kind, + item.pluginId, + item.providerId, + item.itemId, + ]); } - return `${item.kind}-${item.path}-${index}`; + if (item.kind === "thread") { + return JSON.stringify([item.kind, item.threadId]); + } + if (item.kind === "project") { + return JSON.stringify([item.kind, item.projectId]); + } + return JSON.stringify([item.kind, item.sectionId]); } type CommandSectionKind = ProviderCommandSection; @@ -191,8 +207,18 @@ function getMentionIcon(item: PromptMentionSuggestion): ReactNode { ); } -function getCommandKey(item: ComposerCommandSuggestion, index: number): string { - return `command-${item.source}-${item.origin}-${item.name}-${index}`; +function getCommandKey(item: ComposerCommandSuggestion): string { + return JSON.stringify([ + item.kind, + item.source, + item.origin, + item.pluginId ?? null, + item.name, + ]); +} + +export function typeaheadSuggestionKey(item: TypeaheadSuggestion): string { + return item.kind === "command" ? getCommandKey(item) : getMentionKey(item); } function MutedTrailing({ children }: { children: string }) { @@ -375,7 +401,7 @@ function MentionResults({ return ( onApply(item)} itemRefs={itemRefs} /> @@ -434,7 +460,7 @@ function CommandResults({
{section.items.map(({ item, index }) => ( } title={item.description ?? item.name} - rowKey={getCommandKey(item, index)} + rowKey={getCommandKey(item)} onApply={() => onApply(item)} itemRefs={itemRefs} /> From edbf224c97df1585cf9108b2990428d8465a9b94 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 28 Aug 2026 18:13:37 -0700 Subject: [PATCH 10/10] Fix typeahead regression selectors --- apps/app/src/components/promptbox/PromptBoxInternal.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 90b212c238..8df62039a1 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -3187,7 +3187,7 @@ describe("PromptBoxInternal mention triggers", () => { await focusPromptEnd(promptBoxRef); const sectionButton = await screen.findByRole("button", { - name: "Section: Atlas planning", + name: "Atlas planning", }); fireEvent.keyDown(getPromptEditorElement(), { key: "ArrowDown" }); await waitFor(() => @@ -3202,7 +3202,7 @@ describe("PromptBoxInternal mention triggers", () => { ]} />, ); - await screen.findByRole("button", { name: "Installed: Atlas" }); + await screen.findByRole("button", { name: "Atlas" }); await waitFor(() => expect(sectionButton.className).toContain("bg-state-active"), );