Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
85d3cd2339 feat(editor): rank mention dropdown by per-device recency
Members and agents previously appeared in fixed buckets (members first,
then agents) following raw cache order. Replace that with a single ranked
list driven by the user's most recent mentions on this device, with an
alphabetical fallback for never-mentioned targets. Recency is stored in
localStorage per workspace and lazy-pruned at 200 entries.
2026-04-29 14:53:53 +08:00
2 changed files with 119 additions and 2 deletions

View File

@@ -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<string, number>;
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<MentionItem, "type" | "id">): string {
return `${item.type}:${item.id}`;
}
export function recordMentionUsage(
workspaceId: string,
item: Pick<MentionItem, "type" | "id">,
): 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);
});
}

View File

@@ -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<MentionListRef, MentionListProps>(
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 {