From d1dbb6b8ea32042ada080ec9fa65099f52ad91bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 28 Aug 2026 22:47:27 +0000 Subject: [PATCH] feat(web): rank chat mentions by recency (#3099) --- .../web/components/task/chat/tiptap-input.tsx | 47 ++-- .../task/chat/tiptap-suggestion.test.ts | 44 +++- .../task/chat/tiptap-suggestion.tsx | 3 + .../e2e/tests/chat/mention-recency.spec.ts | 92 ++++++++ .../tests/chat/mobile-mention-recency.spec.ts | 96 ++++++++ apps/web/lib/chat-mention-recency.test.ts | 221 ++++++++++++++++++ apps/web/lib/chat-mention-recency.ts | 186 +++++++++++++++ docs/plans/composer-mention-recency/plan.md | 141 +++++++++++ .../task-01-rank-chat-mention-suggestions.md | 126 ++++++++++ docs/specs/ui/README.md | 8 +- .../requirements/composer-mention-recency.md | 75 ++++++ .../system-design/composer-mention-recency.md | 139 +++++++++++ 12 files changed, 1150 insertions(+), 28 deletions(-) create mode 100644 apps/web/e2e/tests/chat/mention-recency.spec.ts create mode 100644 apps/web/e2e/tests/chat/mobile-mention-recency.spec.ts create mode 100644 apps/web/lib/chat-mention-recency.test.ts create mode 100644 apps/web/lib/chat-mention-recency.ts create mode 100644 docs/plans/composer-mention-recency/plan.md create mode 100644 docs/plans/composer-mention-recency/task-01-rank-chat-mention-suggestions.md create mode 100644 docs/specs/ui/requirements/composer-mention-recency.md create mode 100644 docs/specs/ui/system-design/composer-mention-recency.md diff --git a/apps/web/components/task/chat/tiptap-input.tsx b/apps/web/components/task/chat/tiptap-input.tsx index 63607c10c6..fd33d7db99 100644 --- a/apps/web/components/task/chat/tiptap-input.tsx +++ b/apps/web/components/task/chat/tiptap-input.tsx @@ -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( @@ -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>>; @@ -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(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(funct ref, }); useEditorRefSync(editorRef, editor); - const { closeReverseSearch } = overlay; const handleReverseSearchSelect = useReverseSearchSelectHandler( applyHistoryEntry, - closeReverseSearch, + overlay.closeReverseSearch, editor, ); return ( diff --git a/apps/web/components/task/chat/tiptap-suggestion.test.ts b/apps/web/components/task/chat/tiptap-suggestion.test.ts index f7c7a76677..99e612d3c1 100644 --- a/apps/web/components/task/chat/tiptap-suggestion.test.ts +++ b/apps/web/components/task/chat/tiptap-suggestion.test.ts @@ -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); + }); }); diff --git a/apps/web/components/task/chat/tiptap-suggestion.tsx b/apps/web/components/task/chat/tiptap-suggestion.tsx index 4f0ee1de8b..d1e5659256 100644 --- a/apps/web/components/task/chat/tiptap-suggestion.tsx +++ b/apps/web/components/task/chat/tiptap-suggestion.tsx @@ -43,6 +43,7 @@ const EMPTY_SLASH_STATE: MenuState = { export type MentionSuggestionCallbacks = { getItems: (query: string) => Promise; + 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); }, }); }, diff --git a/apps/web/e2e/tests/chat/mention-recency.spec.ts b/apps/web/e2e/tests/chat/mention-recency.spec.ts new file mode 100644 index 0000000000..d205dc667d --- /dev/null +++ b/apps/web/e2e/tests/chat/mention-recency.spec.ts @@ -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 { + 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 { + 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); +}); diff --git a/apps/web/e2e/tests/chat/mobile-mention-recency.spec.ts b/apps/web/e2e/tests/chat/mobile-mention-recency.spec.ts new file mode 100644 index 0000000000..83f17dd2dd --- /dev/null +++ b/apps/web/e2e/tests/chat/mobile-mention-recency.spec.ts @@ -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); + } + }); +}); diff --git a/apps/web/lib/chat-mention-recency.test.ts b/apps/web/lib/chat-mention-recency.test.ts new file mode 100644 index 0000000000..236cadbcd8 --- /dev/null +++ b/apps/web/lib/chat-mention-recency.test.ts @@ -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, + }); + } + }); +}); diff --git a/apps/web/lib/chat-mention-recency.ts b/apps/web/lib/chat-mention-recency.ts new file mode 100644 index 0000000000..a562cff02c --- /dev/null +++ b/apps/web/lib/chat-mention-recency.ts @@ -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 { + 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(); + + 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; +} diff --git a/docs/plans/composer-mention-recency/plan.md b/docs/plans/composer-mention-recency/plan.md new file mode 100644 index 0000000000..fa28d58701 --- /dev/null +++ b/docs/plans/composer-mention-recency/plan.md @@ -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. diff --git a/docs/plans/composer-mention-recency/task-01-rank-chat-mention-suggestions.md b/docs/plans/composer-mention-recency/task-01-rank-chat-mention-suggestions.md new file mode 100644 index 0000000000..e389b5578f --- /dev/null +++ b/docs/plans/composer-mention-recency/task-01-rank-chat-mention-suggestions.md @@ -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. diff --git a/docs/specs/ui/README.md b/docs/specs/ui/README.md index f856689368..69a9e8475c 100644 --- a/docs/specs/ui/README.md +++ b/docs/specs/ui/README.md @@ -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) diff --git a/docs/specs/ui/requirements/composer-mention-recency.md b/docs/specs/ui/requirements/composer-mention-recency.md new file mode 100644 index 0000000000..9301bb1eea --- /dev/null +++ b/docs/specs/ui/requirements/composer-mention-recency.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. diff --git a/docs/specs/ui/system-design/composer-mention-recency.md b/docs/specs/ui/system-design/composer-mention-recency.md new file mode 100644 index 0000000000..9f81552036 --- /dev/null +++ b/docs/specs/ui/system-design/composer-mention-recency.md @@ -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.