feat(web): rank chat mentions by recency (#3099)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user