From 36bc50b1c0dcd9c3ce7f5a7431e2d8f09b9a8da0 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:27:39 +0800 Subject: [PATCH] feat: separation for mentions --- .../editor/plugins/mention/MentionPlugin.tsx | 2 - .../mention/components/MentionDropdown.tsx | 66 ++++++++++++- .../mention/hooks/dateMentionConfig.ts | 2 +- .../plugins/mention/hooks/useMentionSearch.ts | 16 +--- .../mention/hooks/useMentionSearchService.ts | 13 ++- .../shared/components/TypeaheadDropdown.tsx | 95 +++++++++++++++---- locales/ai/en.json | 5 + locales/ai/ja.json | 5 + locales/ai/zh-CN.json | 5 + 9 files changed, 166 insertions(+), 43 deletions(-) diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/MentionPlugin.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/MentionPlugin.tsx index 22b898c75..57f62b22d 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/MentionPlugin.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/MentionPlugin.tsx @@ -2,7 +2,6 @@ import * as React from "react" import { Suspense, useMemo } from "react" import { MentionDropdown } from "./components/MentionDropdown" -import { DEFAULT_MAX_SUGGESTIONS } from "./constants" import { useMentionKeyboard } from "./hooks/useMentionKeyboard" import { useMentionSearch } from "./hooks/useMentionSearch" import { useMentionSearchService } from "./hooks/useMentionSearchService" @@ -31,7 +30,6 @@ export function MentionPlugin() { hasResults, } = useMentionSearch({ onSearch: searchMentions, - maxSuggestions: DEFAULT_MAX_SUGGESTIONS, }) // Hook for handling mention selection diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/components/MentionDropdown.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/components/MentionDropdown.tsx index 491aa87e9..ef37ca9e0 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/components/MentionDropdown.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/components/MentionDropdown.tsx @@ -1,8 +1,9 @@ import { cn, thenable } from "@follow/utils" import * as React from "react" -import { useCallback } from "react" +import { useCallback, useMemo } from "react" import { useTranslation } from "react-i18next" +import type { TypeaheadGroup } from "../../shared/components/TypeaheadDropdown" import { TypeaheadDropdown } from "../../shared/components/TypeaheadDropdown" import { MENTION_TRIGGER_PATTERN } from "../constants" import { RANGE_WITH_LABEL_KEY } from "../hooks/dateMentionConfig" @@ -110,6 +111,41 @@ const MentionSuggestionItem = React.memo( MentionSuggestionItem.displayName = "MentionSuggestionItem" +const MentionGroupHeader = React.memo(({ type }: { type: MentionData["type"] }) => { + const { t } = useTranslation("ai") + + const label = useMemo(() => { + switch (type) { + case "date": { + return t("mentions.section.date") + } + case "entry": { + return t("mentions.section.entry") + } + case "feed": { + return t("mentions.section.feed") + } + case "category": { + return t("mentions.section.category") + } + case "view": { + return t("mentions.section.view") + } + default: { + return "" + } + } + }, [type, t]) + + return ( +
+ {label} +
+ ) +}) + +MentionGroupHeader.displayName = "MentionGroupHeader" + export const MentionDropdown: React.FC = ({ isVisible, suggestions, @@ -125,10 +161,30 @@ export const MentionDropdown: React.FC = ({ }) => { if (!isVisible) throw thenable + // Group suggestions by type + const groupedSuggestions = useMemo[]>(() => { + const groups: TypeaheadGroup[] = [] + let currentType: MentionData["type"] | null = null + + suggestions.forEach((mention) => { + if (mention.type !== currentType) { + currentType = mention.type + groups.push({ key: currentType, items: [mention] }) + } else { + const lastGroup = groups.at(-1) + if (lastGroup) { + lastGroup.items.push(mention) + } + } + }) + + return groups + }, [suggestions]) + return ( - isVisible={isVisible} - items={suggestions} + items={groupedSuggestions} selectedIndex={selectedIndex} isLoading={isLoading} onSelect={onSelect} @@ -136,10 +192,9 @@ export const MentionDropdown: React.FC = ({ onClose={onClose} query={query} ariaLabel="Mention suggestions" - getKey={(m) => `${m.type}-${m.id}`} + getKey={(mention) => `${mention.type}-${mention.id}`} renderItem={(mention, _index, isSelected, handlers) => ( = ({ query={query} /> )} + renderGroupHeader={(groupKey) => } anchor={anchor} showSearchInput={showSearchInput} onQueryChange={onQueryChange} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/dateMentionConfig.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/dateMentionConfig.ts index b4ada16ba..a858bbfe8 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/dateMentionConfig.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/dateMentionConfig.ts @@ -2,7 +2,7 @@ import type { Dayjs } from "dayjs" import { clampRangeToPastMonth } from "./dateMentionUtils" -export const MAX_INLINE_DATE_SUGGESTIONS = 3 +export const MAX_INLINE_DATE_SUGGESTIONS = 2 export type DateRangeFactory = (today: Dayjs) => ReturnType diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/useMentionSearch.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/useMentionSearch.ts index 685bc3125..7472d63b3 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/useMentionSearch.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/useMentionSearch.ts @@ -1,6 +1,5 @@ import { useCallback, useRef, useState, useTransition } from "react" -import { DEFAULT_MAX_SUGGESTIONS } from "../constants" import type { MentionData, MentionSearchState, MentionType } from "../types" import { getMentionType, shouldTriggerMention } from "../utils/triggerDetection" @@ -8,17 +7,14 @@ interface UseMentionSearchOptions { onSearch?: ( query: string, type: MentionType | undefined, + maxSuggestions?: number, ) => Promise | MentionData[] - maxSuggestions?: number } // Default search function const defaultSearchFn = async (): Promise => [] -export const useMentionSearch = ({ - onSearch = defaultSearchFn, - maxSuggestions = DEFAULT_MAX_SUGGESTIONS, -}: UseMentionSearchOptions = {}) => { +export const useMentionSearch = ({ onSearch = defaultSearchFn }: UseMentionSearchOptions = {}) => { const [searchState, setSearchState] = useState({ suggestions: [], selectedIndex: -1, @@ -27,15 +23,13 @@ export const useMentionSearch = ({ const [isPending, startTransition] = useTransition() const onSearchRef = useRef(onSearch) - const maxSuggestionsRef = useRef(maxSuggestions) const abortControllerRef = useRef(null) // Update refs when props change to avoid stale closures onSearchRef.current = onSearch - maxSuggestionsRef.current = maxSuggestions const searchMentions = useCallback( - async (query: string) => { + async (query: string, maxSuggestions?: number) => { // Cancel any pending search if (abortControllerRef.current) { abortControllerRef.current.abort() @@ -63,7 +57,7 @@ export const useMentionSearch = ({ try { const [mentionType, cleanQuery] = getMentionType(query) - const results = await onSearchRef.current(cleanQuery, mentionType) + const results = await onSearchRef.current(cleanQuery, mentionType, maxSuggestions) // Check if this search was aborted if (abortController.signal.aborted) { @@ -72,7 +66,7 @@ export const useMentionSearch = ({ startTransition(() => { setSearchState({ - suggestions: results.slice(0, maxSuggestionsRef.current), + suggestions: results, selectedIndex: results.length > 0 ? 0 : -1, isLoading: false, }) diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/useMentionSearchService.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/useMentionSearchService.ts index 085dabd67..a91c25c9f 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/useMentionSearchService.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/useMentionSearchService.ts @@ -19,7 +19,11 @@ export const useMentionSearchService = () => { const buildDateMentions = useMemo(() => createDateMentionBuilder({ t, language }), [t, language]) const searchMentions = useMemo(() => { - return async (query: string, type?: MentionType): Promise => { + return async ( + query: string, + type?: MentionType, + maxSuggestions = 10, + ): Promise => { const trimmedQuery = query.trim() const results: MentionData[] = [] const seen = new Set() @@ -38,7 +42,7 @@ export const useMentionSearchService = () => { } if (type === "feed" || type === "entry" || type === "category") { - const searchResults = search(trimmedQuery, type, 10) + const searchResults = search(trimmedQuery, type, maxSuggestions) searchResults.forEach((item) => pushResult({ id: item.id, @@ -71,7 +75,10 @@ export const useMentionSearchService = () => { const dateSuggestions = buildDateMentions(trimmedQuery) dateSuggestions.slice(0, MAX_INLINE_DATE_SUGGESTIONS).forEach(pushResult) - const searchResults = search(trimmedQuery, undefined, 10) + // Calculate remaining slots for search results + const remainingSlots = Math.max(0, maxSuggestions - results.length) + + const searchResults = search(trimmedQuery, undefined, remainingSlots) searchResults.forEach((item) => pushResult({ id: item.id, diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shared/components/TypeaheadDropdown.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shared/components/TypeaheadDropdown.tsx index 0a01a65fa..22aa89d57 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shared/components/TypeaheadDropdown.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/shared/components/TypeaheadDropdown.tsx @@ -16,9 +16,14 @@ import { useEffect, useMemo, useRef, useState } from "react" import { calculateDropdownPosition } from "../utils/positioning" -export interface TypeaheadDropdownProps { - isVisible: boolean +export interface TypeaheadGroup { + key: TGroupKey items: TItem[] +} + +export interface TypeaheadDropdownProps { + isVisible: boolean + items: TItem[] | TypeaheadGroup[] selectedIndex: number isLoading: boolean onSelect: (item: TItem) => void @@ -39,6 +44,8 @@ export interface TypeaheadDropdownProps { anchor?: HTMLElement | null showSearchInput?: boolean onQueryChange?: (query: string) => void + // Group support + renderGroupHeader?: (groupKey: TGroupKey) => React.ReactNode } function useOptionalLexicalEditor() { @@ -50,7 +57,7 @@ function useOptionalLexicalEditor() { } } -export function TypeaheadDropdown({ +export function TypeaheadDropdown({ isVisible, items, selectedIndex, @@ -68,13 +75,24 @@ export function TypeaheadDropdown({ anchor, showSearchInput = false, onQueryChange, -}: TypeaheadDropdownProps) { + renderGroupHeader, +}: TypeaheadDropdownProps) { if (!isVisible) throw thenable const editor = useOptionalLexicalEditor() const dropdownRef = useRef(null) const [referenceWidth, setReferenceWidth] = useState(320) + // Check if items are grouped + const isGrouped = + items.length > 0 && typeof items[0] === "object" && items[0] !== null && "key" in items[0] + + // Flatten grouped items for selection logic + const flatItems = useMemo(() => { + if (!isGrouped) return items as TItem[] + return (items as TypeaheadGroup[]).flatMap((group) => group.items) + }, [items, isGrouped]) + const virtualReference = useRef({ getBoundingClientRect: () => { // If anchor is provided, use it @@ -192,7 +210,8 @@ export function TypeaheadDropdown({ ) } - if (items.length === 0) { + const totalItems = isGrouped ? flatItems.length : items.length + if (totalItems === 0) { return (
{emptyMessage} @@ -201,20 +220,49 @@ export function TypeaheadDropdown({ ) } + if (!isGrouped) { + // Render flat list + return ( +
+ {(items as TItem[]).map((item, index) => { + const isSelected = index === selectedIndex + const handlers = { + onMouseMove: () => onSetSelectIndex(index), + onClick: () => onSelect(item), + } + return ( + + {renderItem(item, index, isSelected, handlers)} + + ) + })} +
+ ) + } + + // Render grouped list + let itemIndex = 0 return (
- {items.map((item, index) => { - const isSelected = index === selectedIndex - const handlers = { - onMouseMove: () => onSetSelectIndex(index), - onClick: () => onSelect(item), - } - return ( - - {renderItem(item, index, isSelected, handlers)} - - ) - })} + {(items as TypeaheadGroup[]).map((group) => ( + + {renderGroupHeader && renderGroupHeader(group.key)} + {group.items.map((item) => { + const currentIndex = itemIndex + itemIndex++ + const isSelected = currentIndex === selectedIndex + const handlers = { + onMouseMove: () => onSetSelectIndex(currentIndex), + onClick: () => onSelect(item), + } + return ( + + {renderItem(item, currentIndex, isSelected, handlers)} + + ) + })} + + ))}
) }, [ @@ -230,6 +278,9 @@ export function TypeaheadDropdown({ emptyHint, onSetSelectIndex, onSelect, + isGrouped, + flatItems, + renderGroupHeader, ]) return ( @@ -260,10 +311,12 @@ export function TypeaheadDropdown({ value={query} onChange={(e) => onQueryChange(e.target.value)} onKeyDown={(e) => { - const suggestion = items[selectedIndex] || items[0] - if (e.key === "Enter" && suggestion) { - e.preventDefault() - onSelect(suggestion) + if (e.key === "Enter") { + const suggestion = flatItems[selectedIndex] || flatItems[0] + if (suggestion) { + e.preventDefault() + onSelect(suggestion) + } } }} placeholder="Search for context..." diff --git a/locales/ai/en.json b/locales/ai/en.json index 4024b3b63..5bce59634 100644 --- a/locales/ai/en.json +++ b/locales/ai/en.json @@ -121,6 +121,11 @@ "mentions.date.weekday.prefix.last.search": "last|last week", "mentions.date.weekday.prefix.this.search": "this|this week", "mentions.date.weekday.this.label": "This {{weekday}}", + "mentions.section.category": "Categories", + "mentions.section.date": "Dates", + "mentions.section.entry": "Entries", + "mentions.section.feed": "Feeds", + "mentions.section.view": "Views", "personalize.description": "Tell me about yourself to get personalized AI responses.", "personalize.prompt.help": "This helps AI provide personalized responses based on your preferences.", "personalize.prompt.placeholder": "Tell me about yourself and how you prefer to read content...", diff --git a/locales/ai/ja.json b/locales/ai/ja.json index 56df9744f..a24b8bf00 100644 --- a/locales/ai/ja.json +++ b/locales/ai/ja.json @@ -115,6 +115,11 @@ "mentions.date.weekday.prefix.last.search": "先週|せんしゅう", "mentions.date.weekday.prefix.this.search": "今週|こんしゅう", "mentions.date.weekday.this.label": "今週{{weekday}}", + "mentions.section.category": "カテゴリー", + "mentions.section.date": "日付", + "mentions.section.entry": "エントリー", + "mentions.section.feed": "フィード", + "mentions.section.view": "ビュー", "personalize.description": "あなた自身について教えてください。そうすれば、パーソナライズされたAIの応答が得られます。", "personalize.prompt.help": "これにより、AIはあなたの好みに基づいてパーソナライズされた応答を提供できます。", "personalize.prompt.placeholder": "あなた自身について、どのようにコンテンツを読みたいかを教えてください...", diff --git a/locales/ai/zh-CN.json b/locales/ai/zh-CN.json index a8c7afa8d..de9569e7a 100644 --- a/locales/ai/zh-CN.json +++ b/locales/ai/zh-CN.json @@ -117,6 +117,11 @@ "mentions.date.weekday.prefix.last.search": "上周|上一周|上星期|上个星期|上礼拜|上个礼拜", "mentions.date.weekday.prefix.this.search": "这周|本周|这星期|本星期|这礼拜|本礼拜", "mentions.date.weekday.this.label": "本周{{weekday}}", + "mentions.section.category": "分类", + "mentions.section.date": "日期", + "mentions.section.entry": "条目", + "mentions.section.feed": "订阅源", + "mentions.section.view": "视图", "personalize.description": "介绍一下您自己,以获得个性化的AI响应。", "personalize.prompt.help": "这有助于AI根据您的偏好提供个性化响应。", "personalize.prompt.placeholder": "介绍一下您自己以及您希望如何阅读内容...",