diff --git a/apps/app/.ladle/story-fixtures.ts b/apps/app/.ladle/story-fixtures.ts index 11840408fe..85485238c7 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"; @@ -60,7 +61,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 abbdc64dfd..31f6cd8ae9 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, @@ -284,7 +285,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 8e7860f1e9..d4aa5c3a8a 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 0cc97b3c26..3a5b46c7bc 100644 --- a/apps/app/src/components/promptbox/NewThreadComposer.tsx +++ b/apps/app/src/components/promptbox/NewThreadComposer.tsx @@ -1211,7 +1211,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 6ef789788e..261c7210ad 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, @@ -119,7 +120,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 f5846b1898..a33dcd6d78 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.ipados.test.tsx @@ -24,6 +24,7 @@ vi.hoisted(() => { }); import type { PromptTextMention } from "@bb/domain"; +import { EMPTY_ORDERED_MENTION_SUGGESTIONS } from "@bb/client-core"; import { useState } from "react"; import { act, @@ -91,7 +92,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(), @@ -156,7 +157,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(), @@ -193,7 +194,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 3a304cac89..56f743cc68 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", @@ -817,7 +818,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 d992130073..8df62039a1 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"]; } = {}, ) { @@ -3106,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: "Atlas planning", + }); + fireEvent.keyDown(getPromptEditorElement(), { key: "ArrowDown" }); + await waitFor(() => + expect(sectionButton.className).toContain("bg-state-active"), + ); + + view.rerender( + , + ); + await screen.findByRole("button", { name: "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", () => { @@ -3753,7 +3862,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 a16e9b1e68..940d37723f 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, @@ -118,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"; @@ -278,7 +283,7 @@ function PromptSubmitButton({ export interface TypeaheadMentionConfig { triggers?: readonly PluginMentionTrigger[]; - suggestions: readonly PromptMentionSuggestion[]; + results: OrderedMentionSuggestions; isLoading: boolean; isError: boolean; onQueryChange: ( @@ -1127,7 +1132,7 @@ export function PromptBoxInternal({ } = submission; const { triggers: mentionTriggerChars = DEFAULT_TYPEAHEAD_MENTION_TRIGGERS, - suggestions: mentionSuggestions, + results: mentionResults, isLoading: mentionLoading, isError: mentionError, onQueryChange: onMentionQueryChange, @@ -1208,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, ); @@ -1544,7 +1551,7 @@ export function PromptBoxInternal({ : ""; if (nextKey !== triggerKeyRef.current) { triggerKeyRef.current = nextKey; - setSelectedIndex(0); + setSelectedSuggestionKey(null); } setActiveTrigger(nextTrigger); @@ -2037,10 +2044,18 @@ export function PromptBoxInternal({ activeTriggerKind === "command" ? orderedCommandSuggestions : activeTriggerKind === "mention" - ? mentionSuggestions + ? mentionResults.suggestions : [], - [activeTriggerKind, mentionSuggestions, orderedCommandSuggestions], + [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() : ""; @@ -2051,7 +2066,7 @@ export function PromptBoxInternal({ ? { kind: "loading" } : mentionError ? { kind: "error" } - : { kind: "results", suggestions: mentionSuggestions }; + : { kind: "results", results: mentionResults }; const commandMenuState: CommandMenuState = commandLoading ? { kind: "loading" } @@ -2106,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 ( @@ -2180,7 +2194,7 @@ export function PromptBoxInternal({ }; isRestoringAppliedMentionRef.current = true; setActiveTrigger(null); - setSelectedIndex(0); + setSelectedSuggestionKey(null); onMentionQueryChange(null, null); try { @@ -2236,7 +2250,7 @@ export function PromptBoxInternal({ }; isRestoringAppliedMentionRef.current = true; setActiveTrigger(null); - setSelectedIndex(0); + setSelectedSuggestionKey(null); onCommandQueryChange(null); try { @@ -2420,7 +2434,7 @@ export function PromptBoxInternal({ dismissedTriggerRef.current = null; isRestoringAppliedMentionRef.current = true; setActiveTrigger(null); - setSelectedIndex(0); + setSelectedSuggestionKey(null); onCommandQueryChange(null); try { @@ -2454,7 +2468,7 @@ export function PromptBoxInternal({ triggerKeyRef.current = ""; dismissedTriggerRef.current = null; - setSelectedIndex(0); + setSelectedSuggestionKey(null); currentEditor .chain() .focus() @@ -2722,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 ( @@ -2731,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 1abafcb468..2a4f00456d 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"; export type TypeaheadSuggestion = @@ -39,6 +41,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; @@ -74,82 +92,11 @@ function groupSections(args: { return [...sectionsByKind.values()]; } -type PathMentionSectionKind = "workspace" | "thread-storage"; -type PluginMentionSectionKind = `plugin:${string}`; -type MentionSectionKind = - | "threads" - | "projects" - | "sections" - | PathMentionSectionKind - | PluginMentionSectionKind; type PathMentionSuggestion = Extract; type SecondaryContextKind = "path" | "project"; -function getPluginSectionKind( - item: Extract, -): PluginMentionSectionKind { - return `plugin:${item.pluginId}:${item.providerId}`; -} - -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); - } - 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"; @@ -173,17 +120,33 @@ 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 { +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; @@ -244,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 }) { @@ -376,28 +349,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 @@ -407,14 +365,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; @@ -442,7 +401,7 @@ function MentionResults({ return ( onApply(item)} itemRefs={itemRefs} /> @@ -475,13 +434,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({ @@ -507,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} /> @@ -537,6 +490,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, @@ -566,8 +542,7 @@ export function MentionMenu({ ); const innerState = state.state; - const resultsLength = - innerState.kind === "results" ? innerState.suggestions.length : 0; + const resultsLength = typeaheadResultsLength(state); useEffect(() => { itemRefs.current = itemRefs.current.slice(0, resultsLength); @@ -610,9 +585,7 @@ export function MentionMenu({ ) : state.trigger === "command" ? ( ) : ( ({ 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 c6e2c4def6..f7a8b340c3 100644 --- a/apps/app/src/components/thread/embedded-chat/useComposerTypeahead.ts +++ b/apps/app/src/components/thread/embedded-chat/useComposerTypeahead.ts @@ -67,7 +67,7 @@ export function useComposerTypeahead({ () => ({ mention: { triggers: promptMentions.triggers, - suggestions: promptMentions.suggestions, + results: promptMentions.results, isLoading: promptMentions.isLoading, isError: promptMentions.isError, onQueryChange: promptMentions.setQuery, @@ -97,7 +97,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 d6479103fa..a538a8b3f2 100644 --- a/apps/app/src/hooks/pluginMentionSuggestions.ts +++ b/apps/app/src/hooks/pluginMentionSuggestions.ts @@ -1,10 +1,14 @@ import type { PluginMentionSearchGroup } from "./queries/plugin-contribution-queries"; import type { PromptMentionSuggestion } from "@bb/client-core"; +type PluginMentionSuggestion = Extract< + PromptMentionSuggestion, + { kind: "plugin" } +>; 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..3bee14252f --- /dev/null +++ b/apps/app/src/hooks/promptMentionCandidates.test.ts @@ -0,0 +1,79 @@ +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): PluginMentionSuggestion { + return { + kind: "plugin", + pluginId: "at-plugin", + providerId: "installed", + itemId: "installed:automations", + providerLabel: "Installed", + title, + subtitle: "Automation tools", + icon: null, + replacement: title, + }; +} + +describe("buildPromptMentionResults", () => { + it("ranks an exact plugin title ahead of a weaker built-in title", () => { + const results = buildPromptMentionResults({ + query: "automations", + paths: [], + threads: [], + projects: [project("Automations project")], + sections: [], + plugins: [plugin("Automations")], + }); + + expect(results.groups.map((group) => group.label)).toEqual([ + "Installed", + "Projects", + ]); + expect( + results.suggestions.map((suggestion) => suggestion.replacement), + ).toEqual(["Automations", "project:proj_automations"]); + }); + + it("keeps provider sections distinct when their visible labels collide", () => { + const first = plugin("First"); + const second: PluginMentionSuggestion = { + ...plugin("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..29cb34bd3e --- /dev/null +++ b/apps/app/src/hooks/promptMentionCandidates.ts @@ -0,0 +1,159 @@ +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: [], + 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, + ); +} + +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 eb5144eca6..a277d89d37 100644 --- a/apps/app/src/hooks/usePromptMentions.ts +++ b/apps/app/src/hooks/usePromptMentions.ts @@ -22,12 +22,13 @@ import { usePathSuggestions, PATH_SUGGESTION_DEBOUNCE_MS, } from "./usePathSuggestions"; -import 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; @@ -45,40 +46,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[] { - return args.trimmedQuery.includes("/") - ? [ - ...args.pathSuggestions, - ...args.threadSuggestions, - ...args.projectSuggestions, - ...args.sectionSuggestions, - ...args.pluginSuggestions, - ] - : [ - ...args.threadSuggestions, - ...args.projectSuggestions, - ...args.sectionSuggestions, - ...args.pathSuggestions, - ...args.pluginSuggestions, - ]; -} - function buildProjectNamesById( sidebarNavigation: SidebarBootstrapResponse | undefined, ): ReadonlyMap { @@ -269,18 +241,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, @@ -294,7 +264,7 @@ export function usePromptMentions( const isLoading = hasQuery && - suggestions.length === 0 && + results.suggestions.length === 0 && ((includeBuiltInSources && (pathSearch.isDebouncing || pathSearch.isLoading || @@ -317,7 +287,7 @@ export function usePromptMentions( query, triggers: mentionTriggers, setQuery, - suggestions, + results, isLoading, isError, }; diff --git a/packages/client-core/src/index.ts b/packages/client-core/src/index.ts index a46521d409..73d1c5ed58 100644 --- a/packages/client-core/src/index.ts +++ b/packages/client-core/src/index.ts @@ -23,6 +23,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..260a4004fa --- /dev/null +++ b/packages/client-core/src/prompt/mentions/mention-candidates.ts @@ -0,0 +1,154 @@ +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 { + readonly suggestion: PromptMentionSuggestion; + readonly visibleTitle: string; + /** Additional identities, such as a resource id or provider search alias. */ + readonly identityTerms: readonly string[]; + readonly supportingTerms: readonly string[]; + readonly groupKey: string; + readonly groupLabel: string; +} + +/** One intact rendered group after relevance ordering. */ +export interface OrderedMentionSuggestionGroup { + 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[]; +} + +/** + * The exact mention order shared by grouped rendering and flat keyboard + * navigation. `suggestions` is the concatenation of `groups` in order. + */ +export interface OrderedMentionSuggestions { + readonly groups: readonly OrderedMentionSuggestionGroup[]; + readonly suggestions: readonly PromptMentionSuggestion[]; +} + +export const EMPTY_ORDERED_MENTION_SUGGESTIONS: OrderedMentionSuggestions = { + groups: [], + suggestions: [], +}; + +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], + }); + } + + let nextStartIndex = 0; + const groups = [...groupsByKey.values()] + .sort(compareRankedMentionCandidateGroups) + .map((group) => { + const suggestions = group.candidates + .sort(compareRankedMentionCandidates) + .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 c05881cdf3..913b8fc02a 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"; @@ -179,7 +180,7 @@ export type MentionMenuState = | { kind: "error" } | { kind: "results"; - suggestions: readonly PromptMentionSuggestion[]; + results: OrderedMentionSuggestions; }; export type CommandMenuState = 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..7944808561 --- /dev/null +++ b/packages/client-core/test/mention-suggestion-order.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import { + orderMentionCandidates, + type MentionCandidate, + type OrderedMentionSuggestions, + type PromptMentionSuggestion, +} from "../src/index.js"; + +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:${name}`, + replacement: name, + projectId: "p", + threadId: name, + title: name, + }; +} + +function candidate(options: CandidateOptions): MentionCandidate { + return { + 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, + }; +} + +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( + suggestionNames(orderMentionCandidates(candidates, " PLUGIN ")), + ).toEqual(["exact", "prefix", "substring", "supporting"]); + }); + + 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( + suggestionNames(orderMentionCandidates(candidates, "at-plugin")), + ).toEqual(["alias-exact", "title-prefix"]); + }); + + 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( + 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, group.startIndex]), + ).toEqual([ + ["first", "First label", 0], + ["second", "Second label", 2], + ]); + 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", + ]); + }); +});