feat(web): rank chat mentions by recency (#3099)

This commit is contained in:
Carlos Florêncio
2026-08-28 22:47:27 +00:00
committed by GitHub
parent a9af759d42
commit d1dbb6b8ea
12 changed files with 1150 additions and 28 deletions
+23 -24
View File
@@ -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);
},
});
},
@@ -0,0 +1,92 @@
import { type Locator, type Page } from "@playwright/test";
import { test, expect, type SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { SessionPage } from "../../pages/session-page";
import { CHAT_MENTION_RECENCY_STORAGE_KEY } from "../../../lib/chat-mention-recency";
const RECENT_TITLE = "Project-Mention-Recency-Target";
const STRONGER_TITLE = "Mention-Recency-Stronger-Match";
async function createReadyTask(
apiClient: ApiClient,
seedData: SeedData,
title: string,
): Promise<{ id: string }> {
return apiClient.createTaskWithAgent(seedData.workspaceId, title, seedData.agentProfileId, {
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
}
async function openTaskChat(page: Page, taskId: string): Promise<SessionPage> {
await page.goto(`/t/${taskId}`);
const session = new SessionPage(page);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
return session;
}
function visibleEditor(scope: Locator): Locator {
return scope.locator(".tiptap.ProseMirror:visible").first();
}
async function typeMention(editor: Locator, query: string): Promise<Locator> {
await editor.fill("");
await editor.pressSequentially(`@${query}`);
const menu = editor.page().getByRole("listbox", { name: /Mention tasks, files, prompts/i });
await expect(menu).toBeVisible({ timeout: 10_000 });
return menu;
}
// @covers AC-UI-COMPOSER-MENTION-RECENCY-001.1
// @covers AC-UI-COMPOSER-MENTION-RECENCY-001.2
// @covers AC-UI-COMPOSER-MENTION-RECENCY-001.4
// @covers AC-UI-COMPOSER-MENTION-RECENCY-001.6
test("ranks a selected task first and keeps the order after reload", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
await apiClient.seedTask(seedData.workspaceId, RECENT_TITLE, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await apiClient.seedTask(seedData.workspaceId, STRONGER_TITLE, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const active = await createReadyTask(apiClient, seedData, "Mention Recency Active Task");
await testPage.goto("/");
await testPage.evaluate(
(key) => window.localStorage.removeItem(key),
CHAT_MENTION_RECENCY_STORAGE_KEY,
);
const session = await openTaskChat(testPage, active.id);
const editor = visibleEditor(session.activeChat());
const baselineMenu = await typeMention(editor, "mention");
await expect(baselineMenu.getByRole("option").first()).toContainText(STRONGER_TITLE);
await editor.press("Escape");
const uniqueMenu = await typeMention(editor, "Project-Mention");
await uniqueMenu.getByRole("option").filter({ hasText: RECENT_TITLE }).click();
await expect(uniqueMenu).toHaveCount(0);
const rankedMenu = await typeMention(editor, "mention");
await expect(rankedMenu.getByRole("option").first()).toContainText(RECENT_TITLE);
await prCapture.screenshot("desktop-mention-recency-menu", {
caption: "A previously selected task leads the desktop mention menu",
});
await testPage.reload();
const reloadedSession = new SessionPage(testPage);
await reloadedSession.waitForLoad();
const reloadedEditor = visibleEditor(reloadedSession.activeChat());
await expect(reloadedEditor).toBeEditable({ timeout: 30_000 });
const persistedMenu = await typeMention(reloadedEditor, "mention");
await expect(persistedMenu.getByRole("option").first()).toContainText(RECENT_TITLE);
});
@@ -0,0 +1,96 @@
import { type Page } from "@playwright/test";
import { test, expect, type SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { SessionPage } from "../../pages/session-page";
import { waitForPromptInStore } from "../../helpers/settings-prompt-editor";
import { CHAT_MENTION_RECENCY_STORAGE_KEY } from "../../../lib/chat-mention-recency";
const RECENT_PROMPT_NAME = "Project-Mention-Recency-Prompt";
const STRONGER_PROMPT_NAME = "Mention-Recency-Strong-Prompt";
async function openReadyTask(page: Page, apiClient: ApiClient, seedData: SeedData) {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile Mention Recency Active Task",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await page.goto(`/t/${task.id}`);
const session = new SessionPage(page);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 30_000 });
return session;
}
test.describe("Mobile chat mention recency", () => {
test.describe.configure({ timeout: 90_000 });
// @covers AC-UI-COMPOSER-MENTION-RECENCY-001.1
// @covers AC-UI-COMPOSER-MENTION-RECENCY-001.4
// @covers AC-UI-COMPOSER-MENTION-RECENCY-001.8
test("touch selection uses the shared recency order in the mobile popup", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
const recentPrompt = await apiClient.createPrompt(
RECENT_PROMPT_NAME,
"Mobile recent mention prompt",
);
const strongerPrompt = await apiClient.createPrompt(
STRONGER_PROMPT_NAME,
"Mobile stronger mention prompt",
);
try {
await testPage.goto("/");
await testPage.evaluate(
(key) => window.localStorage.removeItem(key),
CHAT_MENTION_RECENCY_STORAGE_KEY,
);
const session = await openReadyTask(testPage, apiClient, seedData);
await waitForPromptInStore(testPage, RECENT_PROMPT_NAME);
await waitForPromptInStore(testPage, STRONGER_PROMPT_NAME);
const editor = await session.composerReady();
await editor.tap();
await editor.fill("");
await editor.pressSequentially("@mention");
const baselineMenu = testPage.getByRole("listbox", {
name: /Mention tasks, files, prompts/i,
});
await expect(baselineMenu).toBeVisible({ timeout: 10_000 });
await expect(baselineMenu.getByRole("option").first()).toContainText(STRONGER_PROMPT_NAME);
await editor.press("Escape");
await editor.fill("");
await editor.pressSequentially("@Project-Mention");
const uniqueMenu = testPage.getByRole("listbox", {
name: /Mention tasks, files, prompts/i,
});
await expect(uniqueMenu).toBeVisible({ timeout: 10_000 });
await uniqueMenu.getByRole("option").filter({ hasText: RECENT_PROMPT_NAME }).tap();
await expect(uniqueMenu).toHaveCount(0);
await editor.fill("");
await editor.pressSequentially("@mention");
const rankedMenu = testPage.getByRole("listbox", {
name: /Mention tasks, files, prompts/i,
});
await expect(rankedMenu).toBeVisible({ timeout: 10_000 });
await expect(rankedMenu.getByRole("option").first()).toContainText(RECENT_PROMPT_NAME);
await prCapture.screenshot("mobile-mention-recency-menu", {
caption: "A touch-selected prompt leads the mobile mention popup",
});
} finally {
await apiClient.deletePrompt(recentPrompt.id).catch(() => undefined);
await apiClient.deletePrompt(strongerPrompt.id).catch(() => undefined);
}
});
});
+221
View File
@@ -0,0 +1,221 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MentionItem } from "@/hooks/use-inline-mention";
import {
CHAT_MENTION_RECENCY_STORAGE_KEY,
MAX_CHAT_MENTION_RECENT_ENTRIES,
getChatMentionRecentEntries,
mentionItemToRecentEntry,
normalizeChatMentionRecentEntries,
rankMentionItems,
recordChatMentionSelection,
} from "./chat-mention-recency";
function makeItem(
kind: MentionItem["kind"],
id: string,
label: string,
taskId?: string,
): MentionItem {
return {
id,
kind,
label,
task:
kind === "task"
? {
taskId: taskId ?? id,
title: label,
workflowId: "workflow-1",
workflowStepId: "step-1",
state: null,
}
: undefined,
onSelect: vi.fn(),
};
}
const WORKSPACE_ID = "workspace-1";
const OTHER_WORKSPACE_ID = "workspace-2";
const FILE_PATH = "src/app.ts";
describe("chat mention recency", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("preserves baseline filtering and stable source order without history", () => {
const items = [
makeItem("prompt", "contains", "Acontainsneedle"),
makeItem("prompt", "word", "A needle word"),
makeItem("prompt", "prefix", "Needle first"),
makeItem("prompt", "same-1", "Same needle"),
makeItem("prompt", "same-2", "Another needle"),
];
expect(rankMentionItems(items, "needle", WORKSPACE_ID, [])).toEqual([
items[2],
items[1],
items[3],
items[4],
items[0],
]);
});
it("ranks a recent weaker text match before an unselected stronger match", () => {
const recent = makeItem("task", "task:recent", "Project task", "recent");
const stronger = makeItem("task", "task:stronger", "Task owner", "stronger");
expect(
rankMentionItems([stronger, recent], "task", WORKSPACE_ID, [
{
kind: "task",
id: "recent",
},
]),
).toEqual([recent, stronger]);
});
it("orders multiple recent candidates by newest selection before text score", () => {
const older = makeItem("task", "task:older", "Older task", "older");
const newer = makeItem("prompt", "prompt:newer", "Newer prompt");
const unselected = makeItem("task", "task:unselected", "Unselected task", "unselected");
expect(
rankMentionItems([older, newer, unselected], "", WORKSPACE_ID, [
{ kind: "prompt", id: newer.id },
{ kind: "task", id: "older" },
]),
).toEqual([newer, older, unselected]);
});
it("keeps the Plan action at its baseline index while reordering candidates", () => {
const first = makeItem("task", "task:first", "First task", "first");
const plan = makeItem("plan", "__plan__", "Plan");
const recent = makeItem("prompt", "prompt-recent", "Recent prompt");
expect(
rankMentionItems([first, plan, recent], "", WORKSPACE_ID, [
{ kind: "prompt", id: recent.id },
]),
).toEqual([recent, plan, first]);
});
it("uses Plan's filtered baseline position for non-empty queries", () => {
const filtered = makeItem("task", "task:filtered", "Unrelated task", "filtered");
const plan = makeItem("plan", "__plan__", "Plan");
const recent = makeItem("prompt", "prompt-recent", "Plan prompt");
expect(
rankMentionItems([filtered, plan, recent], "plan", WORKSPACE_ID, [
{ kind: "prompt", id: recent.id },
]),
).toEqual([plan, recent]);
});
it("scopes file identities to the active workspace", () => {
const sharedPath = makeItem("file", FILE_PATH, FILE_PATH);
const otherPath = makeItem("file", "src/other.ts", "src/other.ts");
expect(
rankMentionItems([otherPath, sharedPath], "", OTHER_WORKSPACE_ID, [
{ kind: "file", id: FILE_PATH, workspaceId: WORKSPACE_ID },
]),
).toEqual([otherPath, sharedPath]);
expect(
rankMentionItems([otherPath, sharedPath], "", WORKSPACE_ID, [
{ kind: "file", id: FILE_PATH, workspaceId: WORKSPACE_ID },
]),
).toEqual([sharedPath, otherPath]);
});
});
describe("chat mention recency storage", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("normalizes malformed history, removes duplicate identities, and bounds the list", () => {
const entries = normalizeChatMentionRecentEntries([
{ kind: "prompt", id: "prompt-1" },
{ kind: "prompt", id: "prompt-1" },
{ kind: "file", id: FILE_PATH },
{ kind: "file", id: FILE_PATH, workspaceId: WORKSPACE_ID },
{ kind: "file", id: FILE_PATH, workspaceId: OTHER_WORKSPACE_ID },
{ kind: "unknown", id: "bad" },
{ kind: "task", id: "" },
null,
]);
expect(entries).toEqual([
{ kind: "prompt", id: "prompt-1" },
{ kind: "file", id: FILE_PATH, workspaceId: WORKSPACE_ID },
{ kind: "file", id: FILE_PATH, workspaceId: OTHER_WORKSPACE_ID },
]);
const bounded = normalizeChatMentionRecentEntries(
Array.from({ length: MAX_CHAT_MENTION_RECENT_ENTRIES + 3 }, (_, index) => ({
kind: "task",
id: `task-${index}`,
})),
);
expect(bounded).toHaveLength(MAX_CHAT_MENTION_RECENT_ENTRIES);
expect(bounded[0]).toEqual({ kind: "task", id: "task-0" });
});
it("persists selections as a bounded newest-first MRU list", () => {
const first = makeItem("task", "task:first", "First", "first");
const second = makeItem("prompt", "prompt:second", "Second");
window.localStorage.setItem(
CHAT_MENTION_RECENCY_STORAGE_KEY,
JSON.stringify(
Array.from({ length: MAX_CHAT_MENTION_RECENT_ENTRIES }, (_, index) => ({
kind: "task",
id: `task-${index}`,
})),
),
);
recordChatMentionSelection(first, WORKSPACE_ID);
recordChatMentionSelection(second, WORKSPACE_ID);
recordChatMentionSelection(first, WORKSPACE_ID);
const stored = getChatMentionRecentEntries();
expect(stored[0]).toEqual({ kind: "task", id: "first" });
expect(stored[1]).toEqual({ kind: "prompt", id: "prompt:second" });
expect(stored).toHaveLength(MAX_CHAT_MENTION_RECENT_ENTRIES);
expect(stored.filter((entry) => entry.kind === "task" && entry.id === "first")).toHaveLength(1);
});
it("does not record Plan or an unscoped file", () => {
const plan = makeItem("plan", "__plan__", "Plan");
const file = makeItem("file", FILE_PATH, FILE_PATH);
expect(mentionItemToRecentEntry(plan, WORKSPACE_ID)).toBeNull();
expect(mentionItemToRecentEntry(file, null)).toBeNull();
recordChatMentionSelection(plan, WORKSPACE_ID);
recordChatMentionSelection(file, null);
expect(getChatMentionRecentEntries()).toEqual([]);
});
it("continues without throwing when browser storage is unavailable", () => {
const originalStorage = window.localStorage;
Object.defineProperty(window, "localStorage", {
configurable: true,
get: () => {
throw new Error("storage unavailable");
},
});
try {
expect(getChatMentionRecentEntries()).toEqual([]);
expect(() =>
recordChatMentionSelection(makeItem("prompt", "prompt-1", "Prompt"), "w-1"),
).not.toThrow();
} finally {
Object.defineProperty(window, "localStorage", {
configurable: true,
value: originalStorage,
});
}
});
});
+186
View File
@@ -0,0 +1,186 @@
import type { MentionItem } from "@/hooks/use-inline-mention";
export const CHAT_MENTION_RECENCY_STORAGE_KEY = "kandev.chatMentionRecency.v1";
export const MAX_CHAT_MENTION_RECENT_ENTRIES = 50;
type ChatMentionRecentKind = "task" | "prompt" | "file";
export type ChatMentionRecentEntry = {
kind: ChatMentionRecentKind;
id: string;
workspaceId?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function nonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function isRecentKind(value: unknown): value is ChatMentionRecentKind {
return value === "task" || value === "prompt" || value === "file";
}
function recentEntryKey(entry: ChatMentionRecentEntry): string {
return `${entry.kind}:${entry.workspaceId ?? ""}:${entry.id}`;
}
function normalizeRecentEntry(value: unknown): ChatMentionRecentEntry | null {
if (!isRecord(value) || !isRecentKind(value.kind)) return null;
const id = nonEmptyString(value.id);
if (!id) return null;
if (value.kind === "file") {
const workspaceId = nonEmptyString(value.workspaceId);
if (!workspaceId) return null;
return { kind: value.kind, id, workspaceId };
}
return { kind: value.kind, id };
}
export function normalizeChatMentionRecentEntries(value: unknown): ChatMentionRecentEntry[] {
if (!Array.isArray(value)) return [];
const normalized: ChatMentionRecentEntry[] = [];
const seen = new Set<string>();
for (const candidate of value) {
const entry = normalizeRecentEntry(candidate);
if (!entry) continue;
const key = recentEntryKey(entry);
if (seen.has(key)) continue;
seen.add(key);
normalized.push(entry);
if (normalized.length === MAX_CHAT_MENTION_RECENT_ENTRIES) break;
}
return normalized;
}
function getLocalStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
export function getChatMentionRecentEntries(): ChatMentionRecentEntry[] {
const storage = getLocalStorage();
if (!storage) return [];
try {
const raw = storage.getItem(CHAT_MENTION_RECENCY_STORAGE_KEY);
return raw ? normalizeChatMentionRecentEntries(JSON.parse(raw)) : [];
} catch {
return [];
}
}
export function mentionItemToRecentEntry(
item: MentionItem,
workspaceId: string | null | undefined,
): ChatMentionRecentEntry | null {
if (item.kind === "plan") return null;
if (item.kind === "file") {
const scopedWorkspaceId = nonEmptyString(workspaceId);
const id = nonEmptyString(item.id);
if (!scopedWorkspaceId || !id) return null;
return { kind: item.kind, id, workspaceId: scopedWorkspaceId };
}
const id = nonEmptyString(item.kind === "task" ? (item.task?.taskId ?? item.id) : item.id);
return id ? { kind: item.kind, id } : null;
}
function writeRecentEntries(entries: ChatMentionRecentEntry[]): void {
const storage = getLocalStorage();
if (!storage) return;
try {
storage.setItem(CHAT_MENTION_RECENCY_STORAGE_KEY, JSON.stringify(entries));
} catch {
// Ignore private browsing, blocked storage, and quota failures.
}
}
export function recordChatMentionSelection(
item: MentionItem,
workspaceId: string | null | undefined,
): ChatMentionRecentEntry[] {
const entry = mentionItemToRecentEntry(item, workspaceId);
if (!entry) return getChatMentionRecentEntries();
const current = getChatMentionRecentEntries().filter(
(candidate) => recentEntryKey(candidate) !== recentEntryKey(entry),
);
const updated = [entry, ...current].slice(0, MAX_CHAT_MENTION_RECENT_ENTRIES);
writeRecentEntries(updated);
return updated;
}
type ScoredMentionItem = {
item: MentionItem;
score: number;
baselineIndex: number;
};
function textMatchScore(label: string, query: string): number {
if (!query) return 0;
const lowerLabel = label.toLowerCase();
if (lowerLabel.startsWith(query)) return 100;
if (lowerLabel.split(/[\s\-_/]/).some((word) => word.startsWith(query))) return 50;
if (lowerLabel.includes(query)) return 25;
return 0;
}
function buildBaseline(items: MentionItem[], query: string): ScoredMentionItem[] {
const lowerQuery = query.toLowerCase();
return items
.map((item, baselineIndex) => ({
item,
score: textMatchScore(item.label, lowerQuery),
baselineIndex,
}))
.filter(({ score }) => !query || score > 0)
.sort((a, b) => b.score - a.score || a.baselineIndex - b.baselineIndex);
}
export function rankMentionItems(
items: MentionItem[],
query: string,
workspaceId: string | null | undefined,
recentEntries: readonly ChatMentionRecentEntry[] = getChatMentionRecentEntries(),
): MentionItem[] {
const baseline = buildBaseline(items, query);
const normalizedRecentEntries = normalizeChatMentionRecentEntries(recentEntries);
if (normalizedRecentEntries.length === 0) return baseline.map(({ item }) => item);
const recentPositions = new Map(
normalizedRecentEntries.map((entry, index) => [recentEntryKey(entry), index]),
);
const planIndex = baseline.findIndex(({ item }) => item.kind === "plan");
const plan = planIndex >= 0 ? baseline[planIndex] : undefined;
const candidates = baseline.filter(({ item }) => item.kind !== "plan");
candidates.sort((a, b) => {
const aEntry = mentionItemToRecentEntry(a.item, workspaceId);
const bEntry = mentionItemToRecentEntry(b.item, workspaceId);
const aRecent = aEntry ? recentPositions.get(recentEntryKey(aEntry)) : undefined;
const bRecent = bEntry ? recentPositions.get(recentEntryKey(bEntry)) : undefined;
const aKnown = aRecent !== undefined;
const bKnown = bRecent !== undefined;
if (aKnown !== bKnown) return aKnown ? -1 : 1;
if (aKnown && bKnown && aRecent !== bRecent) return aRecent! - bRecent!;
return b.score - a.score || a.baselineIndex - b.baselineIndex;
});
const ranked = candidates.map(({ item }) => item);
if (!plan) return ranked;
ranked.splice(Math.min(planIndex, ranked.length), 0, plan.item);
return ranked;
}
+141
View File
@@ -0,0 +1,141 @@
---
created: 2026-08-27
status: done
requirements:
- REQ-UI-COMPOSER-MENTION-RECENCY-001
system_design:
- ../../specs/ui/system-design/composer-mention-recency.md
legacy_specs: []
---
# Implementation Plan: Rank Chat Mentions by Recency
## Overview
Add one browser-local MRU list for task, saved-prompt, and file selections in
chat `@` menus. Apply the MRU rank after candidate lookup and before menu
rendering. Record selection through the shared TipTap command path.
One vertical work order owns the storage helper, rank integration, selection
wiring, unit tests, and desktop and phone E2E proof. These parts share the same
identity and ordering contract, so a split creates no independent result.
## Scope
### In scope
- Persist 50 unique chat mention identities in newest-first order.
- Rank recent task, saved-prompt, and returned file candidates before unselected
candidates.
- Use text relevance and stable source order after recency.
- Preserve the Plan action's baseline position.
- Record pointer, touch, Enter, and Tab selections through one command path.
- Prove desktop reload persistence and phone touch parity.
### Out of scope
- Backend persistence or sync.
- Candidate lookup changes or larger file-search results.
- Recency for task creation, agent launch, `#`, or `/` menus.
- New copy, settings, recent groups, frequency scores, or time decay.
## Technical approach
### Storage and rank
- Add `apps/web/lib/chat-mention-recency.ts` and its unit test.
- Use the versioned key `kandev.chatMentionRecency.v1`.
- Normalize unknown browser data before each read and write.
- Key task and prompt entries by stable ID. Key file entries by workspace and
path.
- Keep one 50-entry newest-first list. Move a repeated selection to the front.
- Export a pure rank helper that preserves current behavior when no history
exists.
### Composer integration
- Pass `workspaceId` into `useMentionItems` in
`apps/web/components/task/chat/tiptap-input.tsx`.
- Replace the local text-only `filterItems` path with the shared rank helper.
- Extend `MentionSuggestionCallbacks` with one selection callback.
- Invoke that callback from the menu command closure after TipTap inserts the
selected mention.
- Keep `MentionMenu`, `PopupMenu`, selected-index reset, and entity-reference
and slash-command plugins unchanged.
### Responsive contract
- Reuse the current popup on desktop and phone.
- Keep the same listbox, row size, focus behavior, touch behavior, and scroll
owner.
- Do not add a mobile branch. Shared ranking produces the same order on both
viewports.
## Tests
| Acceptance criterion | Evidence |
| --- | --- |
| `AC-UI-COMPOSER-MENTION-RECENCY-001.1` | Pure rank unit test and desktop E2E |
| `AC-UI-COMPOSER-MENTION-RECENCY-001.2` | Unit test with a recent weaker text match |
| `AC-UI-COMPOSER-MENTION-RECENCY-001.3` | Unit tests for baseline relevance and stable ties |
| `AC-UI-COMPOSER-MENTION-RECENCY-001.4` | Selection-callback unit test and desktop/phone E2E |
| `AC-UI-COMPOSER-MENTION-RECENCY-001.5` | Pure rank unit tests with empty and filtered queries |
| `AC-UI-COMPOSER-MENTION-RECENCY-001.6` | Storage recovery unit tests and reload E2E |
| `AC-UI-COMPOSER-MENTION-RECENCY-001.7` | Identity and workspace-scope unit tests |
| `AC-UI-COMPOSER-MENTION-RECENCY-001.8` | Shared component tests and desktop/phone E2E |
Use Red-Green-Refactor. Run the new unit and E2E assertions before production
changes and record their expected failures in the work-order results.
## E2E tests
- Add `apps/web/e2e/tests/chat/mention-recency.spec.ts` for desktop.
- Seed two task candidates whose shared query gives different text scores.
- Select the weaker text match through a unique query.
- Reopen with the shared query and assert that selection recency wins.
- Reload the page and assert that the same candidate remains first.
- Add `apps/web/e2e/tests/chat/mobile-mention-recency.spec.ts` for
`mobile-chrome`.
- Repeat the rank proof with saved prompts and touch selection in the current
mobile popup.
- Remove disposable prompts and tasks through existing fixture cleanup or
explicit `finally` blocks.
## Work orders
- [x] [Task 01: Rank chat mention suggestions](task-01-rank-chat-mention-suggestions.md)
## Verification results
- Red-Green-Refactor passed. The new selection-callback test failed before the
callback wiring existed, and the new helper tests failed before the helper
module existed.
- Focused web unit tests passed: 2 files, 25 tests, including the filtered-query
Plan-position regression found during PR fixup.
- Managed production-build desktop and `mobile-chrome` E2E tests passed: 1 test
each. Desktop coverage includes same-device reload persistence.
- Web typecheck and targeted web lint passed with no errors or warnings.
- Dependency installation passed with `pnpm install --frozen-lockfile`.
## Risks
- File recency can only reorder the current 20 file-search results. The helper
must not reintroduce stale stored paths as live candidates.
- The keyboard path calls the menu command directly. Recording only the React
row click would miss Enter and Tab.
- A comparator that reads mutable storage during pair comparisons can become
inconsistent. Read and normalize the MRU list once per ranking operation.
- Plan can move if it participates in the MRU comparator. Capture and restore
its baseline index instead.
- File paths can collide across workspaces. Include the workspace in file
identities.
## Public documentation
None. The feature changes no command, configuration key, API, install flow, or
public workflow.
## Decisions
No ADR is required. This local UI feature and its system design contain the
selected ranking and persistence rules.
@@ -0,0 +1,126 @@
---
id: "01-rank-chat-mention-suggestions"
title: "Rank chat mention suggestions"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-COMPOSER-MENTION-RECENCY-001
acceptance_criteria:
- AC-UI-COMPOSER-MENTION-RECENCY-001.1
- AC-UI-COMPOSER-MENTION-RECENCY-001.2
- AC-UI-COMPOSER-MENTION-RECENCY-001.3
- AC-UI-COMPOSER-MENTION-RECENCY-001.4
- AC-UI-COMPOSER-MENTION-RECENCY-001.5
- AC-UI-COMPOSER-MENTION-RECENCY-001.6
- AC-UI-COMPOSER-MENTION-RECENCY-001.7
- AC-UI-COMPOSER-MENTION-RECENCY-001.8
system_design:
- ../../specs/ui/system-design/composer-mention-recency.md
---
# Task 01: Rank Chat Mention Suggestions
## Summary
Add the bounded browser-local MRU helper and connect it to the shared TipTap
chat mention path. Prove the same recency-first order on desktop and phone.
## In scope
- Add normalized storage, identity, update, and rank helpers.
- Rank task, prompt, and returned file candidates by MRU position before text
relevance.
- Preserve the Plan action's baseline position.
- Record pointer, touch, Enter, and Tab selections once.
- Add focused unit, desktop E2E, and phone E2E tests.
## Out of scope
- Backend, API, database, boot-payload, or WebSocket changes.
- New candidate sources or file-search limits.
- Other composer types and suggestion triggers.
- New copy or visual treatment.
## Acceptance
- With no valid history, the helper returns the current filtered order and Plan
position.
- After selection, that candidate ranks before stronger text matches and stays
first after a same-device reload.
- Desktop and phone use the same rank and preserve current insertion, focus,
keyboard, touch, and popup behavior.
## Verification
```bash
cd apps && pnpm install --frozen-lockfile
cd apps/web && pnpm test -- \
lib/chat-mention-recency.test.ts \
components/task/chat/tiptap-suggestion.test.ts
cd apps/web && pnpm e2e:run tests/chat/mention-recency.spec.ts
cd apps/web && pnpm e2e:run --project mobile-chrome \
tests/chat/mobile-mention-recency.spec.ts
cd apps/web && pnpm run typecheck
cd apps && pnpm --filter @kandev/web lint
```
## Files likely touched
- `apps/web/lib/chat-mention-recency.ts`
- `apps/web/lib/chat-mention-recency.test.ts`
- `apps/web/components/task/chat/tiptap-input.tsx`
- `apps/web/components/task/chat/tiptap-suggestion.tsx`
- `apps/web/components/task/chat/tiptap-suggestion.test.ts`
- `apps/web/e2e/tests/chat/mention-recency.spec.ts`
- `apps/web/e2e/tests/chat/mobile-mention-recency.spec.ts`
## Dependencies
None.
## Risks
- The async file source can return after task and prompt candidates. Rank the
completed candidate set once and preserve deterministic ties.
- A storage error must not interrupt mention insertion.
- A missing workspace must not give file paths global recency.
- The test must prove recency over a stronger match, not only a source-order
tie.
## Parallelism
`sequential`
## Inputs
- `docs/specs/ui/requirements/composer-mention-recency.md`
- `docs/specs/ui/system-design/composer-mention-recency.md`
- `apps/web/components/task/chat/tiptap-input.tsx`
- `apps/web/components/task/chat/tiptap-suggestion.tsx`
- `apps/web/lib/recent-tasks.ts` as the nearest browser-storage pattern
- Existing composer mention unit and mobile popup E2E tests
## Results
Implemented device-local MRU ranking and shared TipTap selection recording for
task, saved-prompt, and file mentions. Plan remains at its baseline position,
invalid storage is ignored, and the same ranking path serves desktop and phone.
Red-Green-Refactor evidence:
- The selection-callback test failed before the shared callback was wired.
- The helper tests failed before the new helper module was created.
- Both suites passed after implementation.
- PR fixup added a regression test for Plan's filtered baseline position. It
failed before the fix and passed after the rank helper captured that index.
Verification:
- `cd apps && pnpm install --frozen-lockfile` passed.
- `cd apps/web && pnpm test -- lib/chat-mention-recency.test.ts components/task/chat/tiptap-suggestion.test.ts` passed (2 files, 25 tests).
- `cd apps/web && pnpm e2e:run tests/chat/mention-recency.spec.ts` passed (1 test).
- `cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-mention-recency.spec.ts` passed (1 test).
- `cd apps/web && pnpm run typecheck` passed.
- `cd apps && pnpm --filter @kandev/web lint` passed.
+5 -3
View File
@@ -11,7 +11,7 @@ owners:
## Purpose
The UI system owns web presentation behavior for desktop and mobile surfaces.
The UI system owns web presentation.
## Ownership
@@ -19,8 +19,8 @@ This system owns navigation, settings, boards, task/review surfaces,
walkthroughs, chat controls, visual feedback, and responsive interaction
contracts without backend-state ownership.
Controls that configure or display provider/task state remain owned by that
system. The UI system owns only independent, reusable presentation contracts.
Controls for provider/task state remain owned by that system.
The UI system owns reusable contracts.
## Exclusions
@@ -55,6 +55,7 @@ system. The UI system owns only independent, reusable presentation contracts.
- [Command-panel Sidebar Task Reveal](requirements/command-panel-sidebar-task-reveal.md)
- [Compact Workflow Step Navigation](requirements/compact-workflow-step-navigation.md)
- [Comment Markdown Rendering](requirements/comment-markdown.md)
- [Mention recency](requirements/composer-mention-recency.md)
- [Composer Suggestion Overlays](requirements/composer-suggestion-overlays.md)
- [Context Compaction Count](requirements/context-compaction-count.md)
- [Context Window Reset Freshness](requirements/context-window-reset-freshness.md)
@@ -151,6 +152,7 @@ system. The UI system owns only independent, reusable presentation contracts.
- [Agent Todo List Panel](system-design/agent-todo-list-panel.md)
- [App Status Bar](system-design/app-status-bar.md)
- [Changes File Row Containment](system-design/changes-file-row-containment.md)
- [Mention recency](system-design/composer-mention-recency.md)
- [Composer Suggestion Overlays](system-design/composer-suggestion-overlays.md)
- [Compact Workflow Step Navigation](system-design/compact-workflow-step-navigation.md)
- [Per-workflow column visibility on the kanban board System Design Part 1](system-design/board-step-visibility-filter-01.md)
@@ -0,0 +1,75 @@
---
status: active
system: ui
created: 2026-08-27
owners:
- web
---
# Composer Mention Recency Requirements
## Overview
Chat composers show tasks, saved prompts, files, and the Plan action in one
`@` suggestion menu. The current menu ranks matches by text only. Users who
mention the same items repeatedly must find them again for each message.
The UI system owns this behavior because the selection history is local
presentation state. The task, settings, and workspace-file systems continue to
own the available candidates.
## Terminology
- **Mention candidate:** A task, saved prompt, or file that the current chat
composer can show in its `@` suggestion menu.
- **Recent selection:** A mention candidate that the user selected from a chat
composer on the current device.
- **Baseline rank:** The existing text-match score and stable source order.
## Requirements
### REQ-UI-COMPOSER-MENTION-RECENCY-001: Rank chat mentions by recent selection
**Intent:** Make repeated mentions faster without changing candidate sources or
the menu interaction.
**User story:** As a user, I want recent task, prompt, and file selections first,
so that I can reuse them with less navigation.
#### Acceptance criteria
- **AC-UI-COMPOSER-MENTION-RECENCY-001.1:** When the `@` menu contains recent
and unselected mention candidates, the menu shall show recent candidates
before unselected candidates.
- **AC-UI-COMPOSER-MENTION-RECENCY-001.2:** When two recent candidates are
available, the menu shall show the most recently selected candidate first.
Text-match quality shall rank candidates only after selection recency.
- **AC-UI-COMPOSER-MENTION-RECENCY-001.3:** When two unselected candidates have
different text-match quality, the menu shall preserve the existing text
ranking. Equal candidates shall preserve the existing stable source order.
- **AC-UI-COMPOSER-MENTION-RECENCY-001.4:** When the user selects a task, prompt,
or file by pointer, touch, Enter, or Tab, it shall become the newest recent
selection. Selecting it again shall move it to the front.
- **AC-UI-COMPOSER-MENTION-RECENCY-001.5:** When recency changes other results,
the Plan action shall keep the position that the baseline rank assigned for
the current query. The Plan action shall not enter selection history.
- **AC-UI-COMPOSER-MENTION-RECENCY-001.6:** When the user reloads Kandev on the
same device, the bounded selection history shall continue to rank chat
suggestions. A missing, malformed, or unavailable history shall restore the
baseline rank without blocking selection.
- **AC-UI-COMPOSER-MENTION-RECENCY-001.7:** Task and saved-prompt identities
shall use their stable IDs. File identities shall include the workspace, so
equal paths in different workspaces do not share recency.
- **AC-UI-COMPOSER-MENTION-RECENCY-001.8:** Task chat, Quick Chat, and
passthrough chat shall use the same ranking on desktop and phone. The current
popup, keyboard navigation, focus, insertion, and touch behavior shall not
change.
## Out of scope
- Changing task, saved-prompt, or workspace-file search sources and limits.
- Showing a recent file that the current file search did not return.
- Applying recency to task creation, agent launch, `#` references, or `/`
commands.
- Server storage, user sync, device sync, frequency scoring, or time decay.
- Adding a recent label, group, setting, clear action, or other menu copy.
@@ -0,0 +1,139 @@
---
status: current
system: ui
created: 2026-08-27
requirements:
- REQ-UI-COMPOSER-MENTION-RECENCY-001
---
# Composer Mention Recency System Design
## Purpose and boundaries
This design adds device-local selection recency to the chat composer `@` menu.
It does not change the candidate sources or their search limits.
The chat composer receives tasks from the Kanban store, prompts from settings,
and files from `workspace.files.search`. The UI system ranks these candidates
after each source returns them.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-UI-COMPOSER-MENTION-RECENCY-001` | [Ranking](#ranking), [Selection flow](#selection-flow), [Persistence](#persistence), [Responsive behavior](#responsive-behavior) |
## Components and responsibilities
- A new `apps/web/lib/chat-mention-recency.ts` module owns storage parsing,
bounded MRU updates, candidate identity, and deterministic ranking.
- `useMentionItems` in `apps/web/components/task/chat/tiptap-input.tsx` keeps
candidate loading in its current order. It applies the shared rank after task,
Plan, prompt, and file candidates are available.
- `createMentionSuggestion` in
`apps/web/components/task/chat/tiptap-suggestion.tsx` records a selection in
the same command path for pointer, touch, Enter, and Tab.
- `MentionMenu` and `PopupMenu` keep their current rendering, focus, geometry,
accessibility, and selected-index behavior.
## Data and contracts
The browser key is `kandev.chatMentionRecency.v1`. Its value is a JSON array in
newest-first order. The module limits the normalized array to 50 unique entries.
Each entry has this shape:
```ts
type ChatMentionRecentEntry = {
kind: "task" | "prompt" | "file";
id: string;
workspaceId?: string;
};
```
Task and prompt entries use their stable IDs. File entries require the active
workspace ID and use the returned path as `id`. The module rejects unknown
kinds, empty IDs, and file entries without a workspace ID. It removes duplicate
identities and keeps the newest valid entry.
The stored data contains no task title or prompt content. File entries contain
the path that is necessary to match a later file result.
## Ranking
The rank function accepts the candidates, query, workspace ID, and normalized
MRU entries. It uses this sequence:
1. Apply the existing text filter and score to create the baseline rank.
2. Record the Plan action's index in that baseline rank, then remove the action.
3. Put known MRU candidates before candidates that are not in the MRU list.
4. For known candidates, use the newest-first MRU index.
5. Use the existing text score after recency.
6. Use the baseline index as the final stable tie-breaker.
7. Insert the Plan action at its recorded baseline index.
If the MRU list is empty, this process returns the exact baseline order. A
candidate outside the current source results does not enter the menu.
## Selection flow
1. The user enters an `@` query in a chat composer.
2. `useMentionItems` collects current tasks, Plan, saved prompts, and file search
results.
3. The rank module reads and normalizes the current device history.
4. The composer shows the ranked candidates and selects index zero.
5. The user selects one candidate with pointer, touch, Enter, or Tab.
6. The TipTap command inserts the mention by using the existing behavior.
7. The selection callback moves the candidate identity to the MRU front.
The callback does not record Plan. It records each successful command path once.
The next query or menu open reads the updated list.
## Failure and recovery
- If browser storage is unavailable, reads return an empty history and writes do
nothing. Mention insertion continues.
- If JSON parsing or entry validation fails, the module ignores invalid data and
ranks with the remaining valid entries.
- If a stored candidate no longer exists, it has no rank effect. New selections
remove stale entries through the fixed limit.
- If the workspace ID is unavailable, file candidates use baseline rank and file
selections do not enter history. Tasks and prompts continue to use recency.
## Persistence
The browser owns the MRU list in `localStorage`. No backend API, database
migration, Zustand slice, boot payload, or WebSocket event changes.
MRU order defines recency. The design does not use wall-clock timestamps,
frequency counters, or decay. Clearing browser data resets the ranking.
## Responsive behavior
Desktop and phone use the same `TipTapInput`, rank function, and selection
callback. The current contextual popup remains the presentation on both
viewports.
This change does not add a surface, scroll owner, touch control, or breakpoint.
`PopupMenu` remains the nearest shipped mobile exemplar. Existing viewport
containment, 44-pixel rows, touch selection, and focus behavior remain valid.
A desktop E2E proves selection, primary recency rank, and reload persistence. A
phone E2E proves the same rank after touch selection in the existing popup.
## Security
The history remains inside the Kandev browser origin. The module stores only
candidate identities and file paths. It does not store prompt content, task
titles, queries, or message text.
## Observability
No production telemetry is added. Unit tests cover storage recovery, scope,
bounded updates, ranking priority, stable ties, and Plan position. Browser tests
cover the user-visible desktop and phone flows.
## Related decisions
None. The requirement and this design preserve the local ranking choice. The
feature does not create a cross-system architecture boundary.