diff --git a/packages/views/editor/extensions/mention-recency.ts b/packages/views/editor/extensions/mention-recency.ts new file mode 100644 index 0000000000..5155b5abde --- /dev/null +++ b/packages/views/editor/extensions/mention-recency.ts @@ -0,0 +1,100 @@ +// Tracks the last time the current user mentioned a given target (member / +// agent / issue / "all"), per workspace, in browser storage. Used to rank the +// mention suggestion dropdown so recently-mentioned targets surface first. +// +// Data is per-device by design — the goal is "make the next mention faster", +// not a cross-device profile. If localStorage is unavailable (SSR, sandboxed +// environments) every accessor degrades to a no-op so callers can use it +// unconditionally. + +import type { MentionItem } from "./mention-suggestion"; + +type RecencyMap = Record; + +const STORAGE_PREFIX = "multica:mention-recency:"; +const MAX_ENTRIES = 200; + +function storageKey(workspaceId: string): string { + return `${STORAGE_PREFIX}${workspaceId}`; +} + +function getStorage(): Storage | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +function readRecencyMap(workspaceId: string): RecencyMap { + const storage = getStorage(); + if (!storage) return {}; + const raw = storage.getItem(storageKey(workspaceId)); + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") return parsed as RecencyMap; + } catch { + // Corrupt entry — drop it on the next write rather than throwing. + } + return {}; +} + +function writeRecencyMap(workspaceId: string, map: RecencyMap): void { + const storage = getStorage(); + if (!storage) return; + try { + storage.setItem(storageKey(workspaceId), JSON.stringify(map)); + } catch { + // Quota exceeded or storage disabled — silently skip. + } +} + +function recencyKey(item: Pick): string { + return `${item.type}:${item.id}`; +} + +export function recordMentionUsage( + workspaceId: string, + item: Pick, +): void { + if (!workspaceId) return; + const map = readRecencyMap(workspaceId); + map[recencyKey(item)] = Date.now(); + + // Lazy prune: keep the map bounded so it doesn't grow forever as members + // and agents come and go. + const entries = Object.entries(map); + if (entries.length > MAX_ENTRIES) { + entries.sort(([, ta], [, tb]) => tb - ta); + const trimmed: RecencyMap = {}; + for (const [key, ts] of entries.slice(0, MAX_ENTRIES)) { + trimmed[key] = ts; + } + writeRecencyMap(workspaceId, trimmed); + return; + } + + writeRecencyMap(workspaceId, map); +} + +export function getRecencyMap(workspaceId: string): RecencyMap { + if (!workspaceId) return {}; + return readRecencyMap(workspaceId); +} + +// Sorts user-type mention items (member/agent) by recency DESC, with an +// alphabetical name fallback for items the user has never mentioned. Used to +// merge the previously-separate member and agent buckets into a single list. +export function sortUserItemsByRecency( + items: MentionItem[], + recency: RecencyMap, +): MentionItem[] { + return [...items].sort((a, b) => { + const ra = recency[recencyKey(a)] ?? 0; + const rb = recency[recencyKey(b)] ?? 0; + if (ra !== rb) return rb - ra; + return a.label.localeCompare(b.label); + }); +} diff --git a/packages/views/editor/extensions/mention-suggestion.tsx b/packages/views/editor/extensions/mention-suggestion.tsx index ca4140f37f..7be16eb277 100644 --- a/packages/views/editor/extensions/mention-suggestion.tsx +++ b/packages/views/editor/extensions/mention-suggestion.tsx @@ -27,6 +27,11 @@ import { StatusIcon } from "../../issues/components/status-icon"; import { Badge } from "@multica/ui/components/ui/badge"; import type { IssueStatus } from "@multica/core/types"; import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion"; +import { + getRecencyMap, + recordMentionUsage, + sortUserItemsByRecency, +} from "./mention-recency"; // --------------------------------------------------------------------------- // Types @@ -185,7 +190,10 @@ export const MentionList = forwardRef( const selectItem = useCallback( (index: number) => { const item = displayItems[index]; - if (item) command(item); + if (!item) return; + const wsId = getCurrentWsId(); + if (wsId) recordMentionUsage(wsId, item); + command(item); }, [displayItems, command], ); @@ -374,6 +382,15 @@ export function createMentionSuggestion(qc: QueryClient): Omit< .filter((a) => !a.archived_at && a.name.toLowerCase().includes(q)) .map((a) => ({ id: a.id, label: a.name, type: "agent" as const })); + // Members and agents share a single ranked list — recently mentioned + // targets come first regardless of type, with an alphabetical fallback + // for everyone the user hasn't mentioned yet on this device. + const recency = getRecencyMap(wsId); + const userItems = sortUserItemsByRecency( + [...memberItems, ...agentItems], + recency, + ); + // Cached issues give an instant first paint; MentionList adds server // matches for done/cancelled and any other issues not in this cache. const issueItems: MentionItem[] = cachedIssues @@ -384,7 +401,7 @@ export function createMentionSuggestion(qc: QueryClient): Omit< ) .map(issueToMention); - return [...allItem, ...memberItems, ...agentItems, ...issueItems]; + return [...allItem, ...userItems, ...issueItems]; } return {