From aa300c0189185cc2b0111fa8063f0071edf144a9 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 18:24:24 +0800 Subject: [PATCH] refactor(web): extract @-mention menu into a composable Move the @-mention menu's open/items/active/loading state, the @token detection, debounced search, and insertion logic out of Composer into useMentionMenu. The composer keeps the keydown orchestration (it also juggles the slash menu and history recall) and consumes the returned refs directly; the destructured refs are aliased back to the original names so the rest of the component is unchanged. Move the FileItem view type into types.ts (mirroring the FileData move) so the .ts composable can import it without hitting the type-aware lint rule against importing types from .vue files; MentionMenu re-exports it for the existing .vue consumers. Composer.vue: 2035 -> 1987 lines. Adds unit tests for useMentionMenu. No behavior change. --- .changeset/web-extract-mention-menu.md | 5 + apps/kimi-web/src/components/Composer.vue | 82 ++++------------ apps/kimi-web/src/components/MentionMenu.vue | 8 +- .../src/composables/useMentionMenu.ts | 98 +++++++++++++++++++ apps/kimi-web/src/types.ts | 6 ++ apps/kimi-web/test/mention-menu.test.ts | 97 ++++++++++++++++++ 6 files changed, 227 insertions(+), 69 deletions(-) create mode 100644 .changeset/web-extract-mention-menu.md create mode 100644 apps/kimi-web/src/composables/useMentionMenu.ts create mode 100644 apps/kimi-web/test/mention-menu.test.ts diff --git a/.changeset/web-extract-mention-menu.md b/.changeset/web-extract-mention-menu.md new file mode 100644 index 0000000000..8217f84f07 --- /dev/null +++ b/.changeset/web-extract-mention-menu.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Extract the composer's @-mention menu logic into a reusable composable. diff --git a/apps/kimi-web/src/components/Composer.vue b/apps/kimi-web/src/components/Composer.vue index fa3bf080f3..02c919898b 100644 --- a/apps/kimi-web/src/components/Composer.vue +++ b/apps/kimi-web/src/components/Composer.vue @@ -12,6 +12,7 @@ import { modelThinkingAvailability } from '../lib/modelThinking'; import { draftStorageKey, safeGetString, safeRemove, safeSetString } from '../lib/storage'; import { useInputHistory } from '../composables/useInputHistory'; import { useSlashMenu } from '../composables/useSlashMenu'; +import { useMentionMenu } from '../composables/useMentionMenu'; // --------------------------------------------------------------------------- // Attachment state @@ -170,72 +171,23 @@ const { }); // --------------------------------------------------------------------------- -// @-mention menu +// @-mention menu — see useMentionMenu for the implementation. The composer +// keeps the keydown orchestration because it also juggles the slash menu and +// history recall. // --------------------------------------------------------------------------- - -const mentionOpen = ref(false); -const mentionItems = ref([]); -const mentionActive = ref(0); -const mentionLoading = ref(false); - -// Debounce timer for mention search -let mentionTimer: ReturnType | null = null; - -/** Find the @token under the cursor in the current text value. Returns null if none. */ -function getMentionToken(): { token: string; start: number; end: number } | null { - const val = text.value; - const pos = textareaRef.value?.selectionStart ?? val.length; - // Walk backwards from cursor to find the start of a @token - let start = pos - 1; - while (start >= 0 && !/\s/.test(val[start]!)) { - start--; - } - start++; - const tokenPart = val.slice(start, pos); - if (!tokenPart.startsWith('@')) return null; - // The end of the token is where the cursor is (or after the next space) - return { token: tokenPart.slice(1), start, end: pos }; -} - -function updateMentionMenu(): void { - const mt = getMentionToken(); - if (!mt || !props.searchFiles) { - mentionOpen.value = false; - return; - } - const query = mt.token; - if (mentionTimer !== null) clearTimeout(mentionTimer); - mentionTimer = setTimeout(async () => { - mentionLoading.value = true; - mentionOpen.value = true; - mentionActive.value = 0; - try { - const results = await props.searchFiles!(query); - mentionItems.value = results; - } catch { - mentionItems.value = []; - } finally { - mentionLoading.value = false; - } - }, 200); -} - -function selectMentionItem(item: FileItem): void { - const mt = getMentionToken(); - if (!mt) return; - const val = text.value; - // Replace @query token with the file path - text.value = val.slice(0, mt.start) + item.path + val.slice(mt.end); - mentionOpen.value = false; - void nextTick(() => { - const el = textareaRef.value; - if (!el) return; - const newPos = mt.start + item.path.length; - el.setSelectionRange(newPos, newPos); - el.focus(); - autosize(); - }); -} +const { + open: mentionOpen, + items: mentionItems, + active: mentionActive, + loading: mentionLoading, + update: updateMentionMenu, + select: selectMentionItem, +} = useMentionMenu({ + text, + textareaRef, + autosize, + searchFiles: () => props.searchFiles, +}); // --------------------------------------------------------------------------- // Input event handler — updates both menus diff --git a/apps/kimi-web/src/components/MentionMenu.vue b/apps/kimi-web/src/components/MentionMenu.vue index d6373076bb..c00943c43f 100644 --- a/apps/kimi-web/src/components/MentionMenu.vue +++ b/apps/kimi-web/src/components/MentionMenu.vue @@ -2,11 +2,11 @@