feat(web): rank chat mentions by recency (#3099)
This commit is contained in:
@@ -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);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user