feat(web): rank chat mentions by recency (#3099)

This commit is contained in:
Carlos Florêncio
2026-08-28 22:47:27 +00:00
committed by GitHub
parent a9af759d42
commit d1dbb6b8ea
12 changed files with 1150 additions and 28 deletions
+23 -24
View File
@@ -46,6 +46,7 @@ import { useEntityReferenceComposer } from "./use-entity-reference-composer";
import { EntityReferenceSuggestionPluginKey } from "./tiptap-entity-reference-suggestion";
import type { ImagePasteIssue } from "./clipboard-attachments";
import { useTranslation } from "react-i18next";
import { rankMentionItems, recordChatMentionSelection } from "@/lib/chat-mention-recency";
const RAW_DRAIN = { rawPagination: true } as const;
@@ -76,24 +77,6 @@ type TipTapInputProps = {
onPlanModeChange?: (enabled: boolean) => void;
};
// ── Filter items ────────────────────────────────────────────────────
function filterItems(items: MentionItem[], query: string): MentionItem[] {
if (!query) return items;
const lq = query.toLowerCase();
return items
.map((item) => {
const label = item.label.toLowerCase();
let score = 0;
if (label.startsWith(lq)) score = 100;
else if (label.split(/[\s\-_/]/).some((w) => w.startsWith(lq))) score = 50;
else if (label.includes(lq)) score = 25;
return { item, score };
})
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.map(({ item }) => item);
}
// ── Menu keyboard navigation helper ──────────────────────────────
function handleMenuKeyDown<T>(
@@ -139,13 +122,18 @@ async function fetchFileResults(
return results;
}
function useMentionItems(sessionId: string | null, taskId: string | null) {
function useMentionItems(
sessionId: string | null,
taskId: string | null,
workspaceId: string | null,
) {
const { t } = useTranslation();
const { prompts } = useCustomPrompts();
const storeApi = useAppStoreApi();
const promptsRef = useRef(prompts);
const sessionIdRef = useRef(sessionId);
const taskIdRef = useRef(taskId);
const workspaceIdRef = useRef(workspaceId);
const lastFileSearchRef = useRef<{ query: string; results: string[] }>({
query: "",
results: [],
@@ -154,6 +142,7 @@ function useMentionItems(sessionId: string | null, taskId: string | null) {
promptsRef.current = prompts;
sessionIdRef.current = sessionId;
taskIdRef.current = taskId;
workspaceIdRef.current = workspaceId;
});
return useCallback(
@@ -193,7 +182,7 @@ function useMentionItems(sessionId: string | null, taskId: string | null) {
// ignore
}
}
return filterItems(allItems, query);
return rankMentionItems(allItems, query, workspaceIdRef.current);
},
[storeApi],
);
@@ -204,6 +193,7 @@ function useMentionItems(sessionId: string | null, taskId: string | null) {
type SuggestionConfigsInput = {
sessionId: string | null;
taskId: string | null;
workspaceId: string | null;
onMentionKeyDown: (event: KeyboardEvent) => boolean;
onSlashKeyDown: (event: KeyboardEvent) => boolean;
setMentionMenu: React.Dispatch<React.SetStateAction<MenuState<MentionItem>>>;
@@ -213,6 +203,7 @@ type SuggestionConfigsInput = {
function useSuggestionConfigs({
sessionId,
taskId,
workspaceId,
onMentionKeyDown,
onSlashKeyDown,
setMentionMenu,
@@ -235,9 +226,17 @@ function useSuggestionConfigs({
}));
}, [agentCommands]);
const getMentionItems = useMentionItems(sessionId, taskId);
const workspaceIdRef = useRef(workspaceId);
useLayoutEffect(() => {
workspaceIdRef.current = workspaceId;
});
const getMentionItems = useMentionItems(sessionId, taskId, workspaceId);
const mentionCallbacks = useMemo(
(): MentionSuggestionCallbacks => ({ getItems: getMentionItems }),
(): MentionSuggestionCallbacks => ({
getItems: getMentionItems,
onSelect: (item) => recordChatMentionSelection(item, workspaceIdRef.current),
}),
[getMentionItems],
);
@@ -424,6 +423,7 @@ export const TipTapInput = forwardRef<TipTapInputHandle, TipTapInputProps>(funct
const { mentionSuggestion, slashSuggestion, slashCommands } = useSuggestionConfigs({
sessionId,
taskId: taskId ?? null,
workspaceId,
onMentionKeyDown: menu.onMentionKeyDown,
onSlashKeyDown: menu.onSlashKeyDown,
setMentionMenu: menu.setMentionMenu,
@@ -471,10 +471,9 @@ export const TipTapInput = forwardRef<TipTapInputHandle, TipTapInputProps>(funct
ref,
});
useEditorRefSync(editorRef, editor);
const { closeReverseSearch } = overlay;
const handleReverseSearchSelect = useReverseSearchSelectHandler(
applyHistoryEntry,
closeReverseSearch,
overlay.closeReverseSearch,
editor,
);
return (
@@ -311,7 +311,7 @@ describe("createMentionSuggestion", () => {
onSelect: vi.fn(),
};
const suggestion = createMentionSuggestion(
{ getItems: vi.fn().mockResolvedValue([task, file]) },
{ getItems: vi.fn().mockResolvedValue([task, file]), onSelect: vi.fn() },
vi.fn(),
vi.fn(),
);
@@ -322,4 +322,46 @@ describe("createMentionSuggestion", () => {
expect(items).toEqual([task, file]);
});
it("records selection through the shared command path", () => {
const task: MentionItem = {
id: "task:task-1",
kind: "task",
label: "Selected task",
task: {
taskId: "task-1",
title: "Selected task",
workflowId: "workflow-1",
workflowStepId: "step-1",
state: null,
},
onSelect: vi.fn(),
};
const setMenuState = vi.fn();
const onSelect = vi.fn();
const suggestion = createMentionSuggestion(
{ getItems: vi.fn().mockResolvedValue([task]), onSelect },
setMenuState,
vi.fn(),
);
const lifecycle = suggestion.render?.();
const command = vi.fn();
lifecycle?.onStart?.({
editor: {} as never,
range: { from: 1, to: 2 },
query: "",
text: "@",
items: [task],
command,
decorationNode: null,
clientRect: null,
});
const menu = setMenuState.mock.calls[0]?.[0];
menu?.command?.(task);
expect(command).toHaveBeenCalledOnce();
expect(onSelect).toHaveBeenCalledWith(task);
});
});
@@ -43,6 +43,7 @@ const EMPTY_SLASH_STATE: MenuState<SlashCommand> = {
export type MentionSuggestionCallbacks = {
getItems: (query: string) => Promise<MentionItem[]>;
onSelect: (item: MentionItem) => void;
};
export const MentionSuggestionPluginKey = new PluginKey("mentionSuggestion");
@@ -95,6 +96,7 @@ export function createMentionSuggestion(
clientRect: props.clientRect ?? null,
command: (item: MentionItem) => {
props.command(mentionItemToAttrs(item));
callbacks.onSelect(item);
},
});
},
@@ -107,6 +109,7 @@ export function createMentionSuggestion(
clientRect: props.clientRect ?? null,
command: (item: MentionItem) => {
props.command(mentionItemToAttrs(item));
callbacks.onSelect(item);
},
});
},