From a34ea719dfa902631d84b209a07934751ca67e03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Fri, 29 May 2026 15:52:39 +0100 Subject: [PATCH] feat(web): allow custom prompts in task creation input (#1155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(web): extract measureCaretRect into shared util Move the textarea caret-position measurement helper out of rich-text-input.tsx into apps/web/lib/utils/caret-position.ts so the task-create dialog's autocomplete wiring can reuse it without depending on the chat-only RichTextInput component. Co-Authored-By: Claude Opus 4.7 * feat(web): allow custom prompts in task creation input Add an @-mention autocomplete to the task-create dialog's prompt textarea so users can quickly insert one of their saved custom prompts (built-in + user-created) by typing @. - Extend useInlineMention with a promptInsertMode option. The default "context" mode preserves chat behavior (attach the prompt to the context store). The new "inline" mode replaces the @query text with the prompt's full content, requiring no backend changes -- the resolved text just becomes part of the task description on submit. - Wire the hook into TaskFormInputs via a small useTaskCreatePromptMention helper that adapts the existing textarea ref to the RichTextInputHandle surface the hook expects (focus / selection / caret-rect / value). - Reuse the existing MentionMenu popup component for parity with chat. - Cover the new branches with unit tests for makePromptItem / detectMentionTrigger / filterItems, and an E2E spec exercising the menu open / inline-insertion / Enter-doesn't-submit / trigger-inside-word paths. Co-Authored-By: Claude Opus 4.7 * fix(web): make mention popup clickable inside Radix Dialog Radix Dialog sets pointer-events: none on document.body to enforce modal isolation. The mention/slash menu portals to document.body and inherits the disabled pointer events when opened from inside a Dialog (the task-create flow). The popup was visible but unclickable: keyboard select worked, mouse clicks were swallowed by the Dialog body. Restore pointerEvents: auto on the popup container so it stays clickable when invoked from within a modal. Bump z-index to 60 so the popup also wins the visual stacking race against the Dialog (overlay + content are both z-50). Chat continues to work unchanged since z-60 and pointer-events: auto are no-ops outside a modal. Add an adversarial QA spec covering mouse-click select, ArrowDown + Enter on the 2nd item, Escape close, multi-line content auto-grow, backspace-over-@ close, space-after-@ close, no-arrow-cursor-shift, and end-to-end submit. The mouse-click case is the one this fix unblocks; the rest pin behavior for future regressions. Co-Authored-By: Claude Opus 4.7 * fix(web): address PR review feedback - caret-position.ts: wrap DOM measurement in try/finally so the mirror element is always removed even if getBoundingClientRect throws - useTextareaHandlers: destructure stable fn refs from mention before useCallback so deps are specific properties, not the whole object — avoids defeating memoization on every keystroke - QA spec: consolidate inline cleanupPrompts calls into a shared test.afterEach so cleanup always runs even when a test throws; update submit test comment to accurately reflect what is asserted Co-Authored-By: Claude Sonnet 4.6 * fix(web): tighten ArrowDown selection assertion in QA E2E spec Equal filter scores sort by insertion order (stable), so qa-arr-1 lands at index 0 and qa-arr-2 at index 1. After one ArrowDown from the default the selected item is "SECOND" — assert that directly instead of accepting either value, matching the test title "selects the second prompt". Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 4.7 --- .../task-create-dialog-selectors.tsx | 134 ++++++--- apps/web/components/task/chat/popup-menu.tsx | 9 +- .../components/task/chat/rich-text-input.tsx | 60 +--- ...task-create-prompt-autocomplete-qa.spec.ts | 264 ++++++++++++++++++ .../task-create-prompt-autocomplete.spec.ts | 91 ++++++ apps/web/hooks/use-inline-mention.test.ts | 154 ++++++++++ apps/web/hooks/use-inline-mention.ts | 43 ++- .../hooks/use-task-create-prompt-mention.ts | 60 ++++ apps/web/lib/utils/caret-position.ts | 64 +++++ 9 files changed, 779 insertions(+), 100 deletions(-) create mode 100644 apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts create mode 100644 apps/web/e2e/tests/task/task-create-prompt-autocomplete.spec.ts create mode 100644 apps/web/hooks/use-inline-mention.test.ts create mode 100644 apps/web/hooks/use-task-create-prompt-mention.ts create mode 100644 apps/web/lib/utils/caret-position.ts diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index 4a08a293ec..f58a99d743 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -16,6 +16,7 @@ import { type FileAttachment, } from "@/components/task/chat/file-attachment"; import { ContextZone } from "@/components/task/chat/context-items/context-zone"; +import { MentionMenu } from "@/components/task/chat/mention-menu"; import type { ContextItem, ImageContextItem, FileAttachmentContextItem } from "@/lib/types/context"; import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types"; import { EnhancePromptButton } from "@/components/enhance-prompt-button"; @@ -23,6 +24,7 @@ import { JiraImportBar } from "@/components/jira/jira-import-bar"; import { LinearImportBar } from "@/components/linear/linear-import-bar"; import type { JiraTicket } from "@/lib/types/jira"; import type { LinearIssue } from "@/lib/types/linear"; +import { useTaskCreatePromptMention } from "@/hooks/use-task-create-prompt-mention"; const CURSOR_POINTER_CLASS = "cursor-pointer"; @@ -489,9 +491,8 @@ function useDescriptionInput( textarea.setSelectionRange(textarea.value.length, textarea.value.length); }, [autoFocus]); - const handleDescriptionChange = useCallback( - (e: React.ChangeEvent) => { - const newValue = e.target.value; + const setDescriptionValue = useCallback( + (newValue: string) => { const hadContent = description.trim().length > 0; const hasContent = newValue.trim().length > 0; setDescription(newValue); @@ -500,7 +501,7 @@ function useDescriptionInput( [description, onDescriptionChange], ); - return { description, textareaRef, handleDescriptionChange }; + return { description, textareaRef, setDescriptionValue }; } type FormInputsToolbarProps = { @@ -550,6 +551,88 @@ function FormInputsToolbar({ ); } +function PromptMentionPopover({ + mention, +}: { + mention: ReturnType; +}) { + return ( + + ); +} + +function useTextareaHandlers( + mention: ReturnType, + onKeyDown: TaskFormInputsProps["onKeyDown"], +) { + const { handleChange: mentionHandleChange, handleKeyDown: mentionHandleKeyDown } = mention; + const handleChange = useCallback( + (e: React.ChangeEvent) => mentionHandleChange(e.target.value), + [mentionHandleChange], + ); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + mentionHandleKeyDown(e); + if (e.defaultPrevented) return; + onKeyDown?.(e); + }, + [mentionHandleKeyDown, onKeyDown], + ); + return { handleChange, handleKeyDown }; +} + +function useFileInputClick(addFiles: (files: File[]) => Promise | void) { + const fileInputRef = useRef(null); + const handleAttachClick = useCallback(() => fileInputRef.current?.click(), []); + const handleFileInputChange = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) void addFiles(Array.from(files)); + e.target.value = ""; + }, + [addFiles], + ); + return { fileInputRef, handleAttachClick, handleFileInputChange }; +} + +function HiddenFileInput({ + inputRef, + onChange, +}: { + inputRef: React.RefObject; + onChange: (e: React.ChangeEvent) => void; +}) { + return ( + + ); +} + +function DraggingOverlay({ isDragging }: { isDragging: boolean }) { + if (!isDragging) return null; + return ( +
+ Drop files here +
+ ); +} + export const TaskFormInputs = memo(function TaskFormInputs({ isSessionMode, autoFocus, @@ -565,7 +648,6 @@ export const TaskFormInputs = memo(function TaskFormInputs({ jiraImport, linearImport, }: TaskFormInputsProps) { - const fileInputRef = useRef(null); const { attachments, isDragging, setIsDragging, addFiles, handleRemoveAttachment } = useFileAttachments(); const { handlePaste, handleDragOver, handleDragLeave, handleDrop } = useAttachmentHandlers( @@ -577,22 +659,20 @@ export const TaskFormInputs = memo(function TaskFormInputs({ () => toContextItems(attachments, handleRemoveAttachment), [attachments, handleRemoveAttachment], ); - const { description, textareaRef, handleDescriptionChange } = useDescriptionInput( + const { description, textareaRef, setDescriptionValue } = useDescriptionInput( initialDescription, autoFocus, descriptionValueRef, onDescriptionChange, attachments, ); - const handleAttachClick = useCallback(() => fileInputRef.current?.click(), []); - const handleFileInputChange = useCallback( - (e: React.ChangeEvent) => { - const files = e.target.files; - if (files && files.length > 0) void addFiles(Array.from(files)); - e.target.value = ""; - }, - [addFiles], - ); + const mention = useTaskCreatePromptMention({ + textareaRef, + value: description, + onChange: setDescriptionValue, + }); + const { handleChange, handleKeyDown } = useTextareaHandlers(mention, onKeyDown); + const { fileInputRef, handleAttachClick, handleFileInputChange } = useFileInputClick(addFiles); return (
- +
- {isDragging && ( -
- Drop files here -
- )} + + ); }); diff --git a/apps/web/components/task/chat/popup-menu.tsx b/apps/web/components/task/chat/popup-menu.tsx index 58926ba2c8..ac2d5d1605 100644 --- a/apps/web/components/task/chat/popup-menu.tsx +++ b/apps/web/components/task/chat/popup-menu.tsx @@ -63,13 +63,18 @@ export function PopupMenu({ return null; } - // Calculate position with content-based width + // z-index sits above Radix Dialog overlay/content (both z-50) so the popup + // renders on top when it is invoked from within a modal (e.g. task-create). + // pointer-events: auto restores click-handling on the popup itself when + // Radix Dialog has set pointer-events: none on for modal isolation; + // without this, the popup is visible but unclickable inside a dialog. const menuStyle: React.CSSProperties = { position: "fixed", left: Math.max(8, resolvedPosition.x), maxWidth: Math.min(MENU_MAX_WIDTH, window.innerWidth - resolvedPosition.x - 8), maxHeight: MENU_HEIGHT, - zIndex: 50, + zIndex: 60, + pointerEvents: "auto", ...(placement === "below" ? { top: resolvedPosition.y + 8 } : { bottom: window.innerHeight - resolvedPosition.y + 8 }), diff --git a/apps/web/components/task/chat/rich-text-input.tsx b/apps/web/components/task/chat/rich-text-input.tsx index 85f87b68da..283f453993 100644 --- a/apps/web/components/task/chat/rich-text-input.tsx +++ b/apps/web/components/task/chat/rich-text-input.tsx @@ -9,6 +9,7 @@ import { useCallback, } from "react"; import { cn } from "@/lib/utils"; +import { measureCaretRect } from "@/lib/utils/caret-position"; function isSubmitKeyPress( event: KeyboardEvent, @@ -20,65 +21,6 @@ function isSubmitKeyPress( return event.key === "Enter" && (event.metaKey || event.ctrlKey); } -const MIRROR_STYLES = [ - "fontFamily", - "fontSize", - "fontWeight", - "fontStyle", - "letterSpacing", - "textTransform", - "wordSpacing", - "textIndent", - "whiteSpace", - "wordWrap", - "wordBreak", - "overflowWrap", - "lineHeight", - "padding", - "paddingTop", - "paddingRight", - "paddingBottom", - "paddingLeft", - "borderWidth", - "boxSizing", -] as const; - -function measureCaretRect(textarea: HTMLTextAreaElement, value: string): DOMRect { - const selectionStart = textarea.selectionStart; - const computed = window.getComputedStyle(textarea); - const mirror = document.createElement("div"); - - mirror.style.position = "absolute"; - mirror.style.visibility = "hidden"; - mirror.style.whiteSpace = "pre-wrap"; - mirror.style.wordWrap = "break-word"; - mirror.style.width = `${textarea.clientWidth}px`; - MIRROR_STYLES.forEach((prop) => { - mirror.style[prop as unknown as number] = computed[prop]; - }); - - document.body.appendChild(mirror); - - mirror.textContent = value.substring(0, selectionStart); - const marker = document.createElement("span"); - marker.textContent = "\u200B"; - mirror.appendChild(marker); - - const textareaRect = textarea.getBoundingClientRect(); - const markerRect = marker.getBoundingClientRect(); - const mirrorRect = mirror.getBoundingClientRect(); - const scrollTop = textarea.scrollTop; - - document.body.removeChild(mirror); - - return new DOMRect( - textareaRect.left + (markerRect.left - mirrorRect.left), - textareaRect.top + (markerRect.top - mirrorRect.top) - scrollTop, - 0, - parseInt(computed.lineHeight, 10) || parseInt(computed.fontSize, 10) * 1.2, - ); -} - export type RichTextInputHandle = { focus: () => void; blur: () => void; diff --git a/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts b/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts new file mode 100644 index 0000000000..dcc66075e5 --- /dev/null +++ b/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts @@ -0,0 +1,264 @@ +/** + * Adversarial QA probes for the @-mention prompt autocomplete in task creation. + * Complements task-create-prompt-autocomplete.spec.ts with edge-case coverage. + */ +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; + +const MENU_TITLE = /Mention tasks, files, prompts/i; + +async function cleanupPrompts( + apiClient: { + listPrompts: () => Promise<{ prompts: Array<{ id: string; name: string; builtin: boolean }> }>; + deletePrompt: (id: string) => Promise; + }, + names: string[], +) { + const { prompts } = await apiClient.listPrompts(); + for (const p of prompts) { + if (!p.builtin && names.includes(p.name)) { + await apiClient.deletePrompt(p.id).catch(() => undefined); + } + } +} + +const ALL_QA_PROMPTS = [ + "qa-alpha", + "qa-esc", + "qa-arr-1", + "qa-arr-2", + "qa-mouse", + "qa-multi", + "qa-space", + "qa-back", + "qa-arrow", + "qa-submit", +]; + +test.describe("@-mention autocomplete: adversarial QA", () => { + test.afterEach(async ({ apiClient }) => { + await cleanupPrompts(apiClient, ALL_QA_PROMPTS); + }); + + test("bare @ opens menu with prompts visible", async ({ testPage, apiClient }) => { + test.setTimeout(60_000); + await apiClient.createPrompt("qa-alpha", "alpha-content"); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + + const textarea = testPage.getByTestId("task-description-input"); + await textarea.click(); + await textarea.pressSequentially("@"); + + await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); + await expect(testPage.getByRole("button", { name: /qa-alpha/ })).toBeVisible(); + }); + + test("Escape closes the menu without inserting the prompt", async ({ testPage, apiClient }) => { + test.setTimeout(60_000); + await apiClient.createPrompt("qa-esc", "ESC_CONTENT"); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + + const textarea = testPage.getByTestId("task-description-input"); + await textarea.click(); + await textarea.pressSequentially("@qa-es"); + + await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); + await textarea.press("Escape"); + await expect(testPage.getByText(MENU_TITLE)).not.toBeVisible(); + + // The @query text is preserved (Esc just closes the menu, doesn't undo typing). + await expect(textarea).toHaveValue("@qa-es"); + + // Dialog should still be open — Escape went to the menu, not the dialog. + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + }); + + test("ArrowDown + Enter selects the second prompt", async ({ testPage, apiClient }) => { + test.setTimeout(60_000); + // Both prompts begin with "qa-arr" so the filter narrows to both. + await apiClient.createPrompt("qa-arr-1", "FIRST"); + await apiClient.createPrompt("qa-arr-2", "SECOND"); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + + const textarea = testPage.getByTestId("task-description-input"); + await textarea.click(); + await textarea.pressSequentially("@qa-arr"); + + await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); + // Both should be visible. + await expect(testPage.getByRole("button", { name: /qa-arr-1/ })).toBeVisible(); + await expect(testPage.getByRole("button", { name: /qa-arr-2/ })).toBeVisible(); + + await textarea.press("ArrowDown"); + await textarea.press("Enter"); + + const value = await textarea.inputValue(); + // Equal filter scores → insertion order (stable sort): qa-arr-1 at index 0, + // qa-arr-2 at index 1. One ArrowDown moves from 0 → 1, so "SECOND" is selected. + expect(value).toBe("SECOND"); + await expect(testPage.getByText(MENU_TITLE)).not.toBeVisible(); + }); + + test("clicking a menu item with the mouse inlines the prompt", async ({ + testPage, + apiClient, + }) => { + test.setTimeout(60_000); + await apiClient.createPrompt("qa-mouse", "MOUSE_CONTENT"); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + + const textarea = testPage.getByTestId("task-description-input"); + await textarea.click(); + await textarea.pressSequentially("@qa-mo"); + + await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); + await testPage.getByRole("button", { name: /qa-mouse/ }).click(); + + await expect(textarea).toHaveValue("MOUSE_CONTENT"); + await expect(testPage.getByText(MENU_TITLE)).not.toBeVisible(); + }); + + test("inserted prompt with multi-line content auto-grows the textarea", async ({ + testPage, + apiClient, + }) => { + test.setTimeout(60_000); + const lines = Array.from({ length: 8 }, (_, i) => `line ${i + 1}`).join("\n"); + await apiClient.createPrompt("qa-multi", lines); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + + const textarea = testPage.getByTestId("task-description-input"); + await textarea.click(); + await textarea.pressSequentially("@qa-mu"); + await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); + await textarea.press("Enter"); + + await expect(textarea).toHaveValue(lines); + + // Height should reflect content (8 lines should be taller than the default ~96px min-h). + const height = await textarea.evaluate((el) => (el as HTMLTextAreaElement).scrollHeight); + expect(height).toBeGreaterThan(100); + }); + + test("typing space after @ closes the menu", async ({ testPage, apiClient }) => { + test.setTimeout(60_000); + await apiClient.createPrompt("qa-space", "x"); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + + const textarea = testPage.getByTestId("task-description-input"); + await textarea.click(); + await textarea.pressSequentially("@"); + await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); + await textarea.pressSequentially(" foo"); + // After a space immediately follows @, trigger detection should yield null. + await expect(testPage.getByText(MENU_TITLE)).toHaveCount(0); + }); + + test("backspacing past the @ closes the menu", async ({ testPage, apiClient }) => { + test.setTimeout(60_000); + await apiClient.createPrompt("qa-back", "x"); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + + const textarea = testPage.getByTestId("task-description-input"); + await textarea.click(); + await textarea.pressSequentially("@qa"); + await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); + await textarea.press("Backspace"); + await textarea.press("Backspace"); + await textarea.press("Backspace"); // deletes the @ + await expect(textarea).toHaveValue(""); + await expect(testPage.getByText(MENU_TITLE)).toHaveCount(0); + }); + + test("ArrowUp/ArrowDown don't propagate to native textarea cursor motion", async ({ + testPage, + apiClient, + }) => { + // When the menu is open, the hook calls preventDefault on Arrow keys, so the + // textarea's selection cursor should NOT move. Probe by verifying that + // arrow-up does not change the textarea content/cursor in a way that + // breaks the subsequent Enter selection. + test.setTimeout(60_000); + await apiClient.createPrompt("qa-arrow", "ARROW_CONTENT"); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + + const textarea = testPage.getByTestId("task-description-input"); + await textarea.click(); + await textarea.pressSequentially("@qa-arr"); + await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); + + const before = await textarea.evaluate((el) => (el as HTMLTextAreaElement).selectionStart); + await textarea.press("ArrowDown"); + await textarea.press("ArrowUp"); + const after = await textarea.evaluate((el) => (el as HTMLTextAreaElement).selectionStart); + expect(after).toBe(before); + + await textarea.press("Enter"); + await expect(textarea).toHaveValue("ARROW_CONTENT"); + }); + + test("description with inlined prompt is sent to backend on submit", async ({ + testPage, + apiClient, + }) => { + // Acceptance criterion: the textarea contains the inlined prompt content before submit + // and the form completes successfully. API-level task description verification is a + // follow-up — it requires retrieving the created task by title after creation. + test.setTimeout(60_000); + const content = "INLINED_FROM_PROMPT_PAYLOAD"; + await apiClient.createPrompt("qa-submit", content); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + + await testPage.getByTestId("task-title-input").fill("qa-submit-task"); + const textarea = testPage.getByTestId("task-description-input"); + await textarea.click(); + await textarea.pressSequentially("@qa-su"); + await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); + await textarea.press("Enter"); + await expect(textarea).toHaveValue(content); + + const start = testPage.getByTestId("submit-start-agent"); + await expect(start).toBeEnabled({ timeout: 30_000 }); + await start.click(); + + await expect(testPage.getByTestId("create-task-dialog")).not.toBeVisible({ + timeout: 10_000, + }); + }); +}); diff --git a/apps/web/e2e/tests/task/task-create-prompt-autocomplete.spec.ts b/apps/web/e2e/tests/task/task-create-prompt-autocomplete.spec.ts new file mode 100644 index 0000000000..30db028dfc --- /dev/null +++ b/apps/web/e2e/tests/task/task-create-prompt-autocomplete.spec.ts @@ -0,0 +1,91 @@ +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; + +const PROMPT_NAME = "e2e-bug-template"; +const PROMPT_CONTENT = "Reproduce, isolate, fix with a regression test."; +const MENU_TITLE = /Mention tasks, files, prompts/i; + +test.describe("Task creation: custom prompt autocomplete", () => { + test.afterEach(async ({ apiClient }) => { + const { prompts } = await apiClient.listPrompts(); + for (const p of prompts) { + if (!p.builtin && p.name === PROMPT_NAME) { + await apiClient.deletePrompt(p.id).catch(() => undefined); + } + } + }); + + test("typing @ opens the menu and selecting inlines the prompt content", async ({ + testPage, + apiClient, + }) => { + test.setTimeout(60_000); + + await apiClient.createPrompt(PROMPT_NAME, PROMPT_CONTENT); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + const textarea = testPage.getByTestId("task-description-input"); + await textarea.click(); + await textarea.pressSequentially("@e2e-bu"); + + const menu = testPage.getByText(MENU_TITLE); + await expect(menu).toBeVisible({ timeout: 5_000 }); + await expect(testPage.getByRole("button", { name: new RegExp(PROMPT_NAME) })).toBeVisible(); + + await textarea.press("Enter"); + + await expect(textarea).toHaveValue(PROMPT_CONTENT); + await expect(menu).not.toBeVisible(); + }); + + test("typing @ inside a word does NOT open the menu", async ({ testPage, apiClient }) => { + test.setTimeout(60_000); + + await apiClient.createPrompt(PROMPT_NAME, PROMPT_CONTENT); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + + const textarea = testPage.getByTestId("task-description-input"); + await textarea.click(); + // No preceding whitespace before @ — the trigger is invalid. + await textarea.pressSequentially("foo@bar"); + + await expect(textarea).toHaveValue("foo@bar"); + await expect(testPage.getByText(MENU_TITLE)).toHaveCount(0); + }); + + test("Enter inside the menu does NOT submit the form", async ({ testPage, apiClient }) => { + test.setTimeout(60_000); + + await apiClient.createPrompt(PROMPT_NAME, PROMPT_CONTENT); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + + // Fill title so the form would be otherwise submittable. + await testPage.getByTestId("task-title-input").fill("autocomplete-enter-test"); + const textarea = testPage.getByTestId("task-description-input"); + await textarea.click(); + await textarea.pressSequentially("@e2e-bu"); + + await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); + await textarea.press("Enter"); + + // Dialog must still be open — Enter selected the menu item, not the form submit. + await expect(dialog).toBeVisible(); + await expect(textarea).toHaveValue(PROMPT_CONTENT); + }); +}); diff --git a/apps/web/hooks/use-inline-mention.test.ts b/apps/web/hooks/use-inline-mention.test.ts new file mode 100644 index 0000000000..13014715b0 --- /dev/null +++ b/apps/web/hooks/use-inline-mention.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi } from "vitest"; +import { + makePromptItem, + detectMentionTrigger, + filterItems, + type MentionItem, +} from "./use-inline-mention"; +import type { RichTextInputHandle } from "@/components/task/chat/rich-text-input"; + +function makeFakeInput(value: string, caretPos: number): RichTextInputHandle { + let selStart = caretPos; + let selEnd = caretPos; + return { + focus: vi.fn(), + blur: vi.fn(), + getSelectionStart: () => selStart, + getSelectionEnd: () => selEnd, + setSelectionRange: (start: number, end: number) => { + selStart = start; + selEnd = end; + }, + getCaretRect: () => null, + getValue: () => value, + setValue: vi.fn(), + insertText: vi.fn(), + getTextareaElement: () => null, + }; +} + +describe("makePromptItem — context mode (default chat behavior)", () => { + it("deletes the @query text and calls onPromptSelect", () => { + const prompt = { id: "p1", name: "bug-template", content: "Reproduce, isolate, fix." }; + const onPromptSelect = vi.fn(); + const item = makePromptItem(prompt, "context", onPromptSelect); + + const value = "Hello @bug"; + const triggerStart = 6; + const cursorPos = value.length; + const input = makeFakeInput(value, cursorPos); + const onChange = vi.fn(); + + item.onSelect(input, value, triggerStart, onChange); + + expect(onChange).toHaveBeenCalledWith("Hello "); + expect(onPromptSelect).toHaveBeenCalledWith("p1", "bug-template"); + }); + + it("exposes kind 'prompt' and label = prompt name", () => { + const item = makePromptItem({ id: "p1", name: "foo", content: "bar" }, "context"); + expect(item.kind).toBe("prompt"); + expect(item.label).toBe("foo"); + }); +}); + +describe("makePromptItem — inline mode (task-create behavior)", () => { + it("replaces the @query text with the prompt content", () => { + const prompt = { + id: "p1", + name: "bug-template", + content: "Reproduce, isolate, fix with a regression test.", + }; + const onPromptSelect = vi.fn(); + const item = makePromptItem(prompt, "inline", onPromptSelect); + + const value = "Hello @bug"; + const triggerStart = 6; + const cursorPos = value.length; + const input = makeFakeInput(value, cursorPos); + const onChange = vi.fn(); + + item.onSelect(input, value, triggerStart, onChange); + + expect(onChange).toHaveBeenCalledWith("Hello Reproduce, isolate, fix with a regression test."); + expect(onPromptSelect).not.toHaveBeenCalled(); + }); + + it("places caret at the end of the inserted content", async () => { + const prompt = { id: "p1", name: "p", content: "abc" }; + const item = makePromptItem(prompt, "inline"); + const value = "x @p"; + const triggerStart = 2; + const input = makeFakeInput(value, value.length); + const setSelectionRangeSpy = vi.spyOn(input, "setSelectionRange"); + const focusSpy = vi.spyOn(input, "focus"); + const onChange = vi.fn(); + + item.onSelect(input, value, triggerStart, onChange); + + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + + const expectedCaret = triggerStart + prompt.content.length; + expect(setSelectionRangeSpy).toHaveBeenCalledWith(expectedCaret, expectedCaret); + expect(focusSpy).toHaveBeenCalled(); + }); + + it("preserves text after the cursor", () => { + const prompt = { id: "p1", name: "p", content: "XYZ" }; + const item = makePromptItem(prompt, "inline"); + const value = "before @p after"; + const triggerStart = 7; + const cursorPos = 9; + const input = makeFakeInput(value, cursorPos); + const onChange = vi.fn(); + + item.onSelect(input, value, triggerStart, onChange); + + expect(onChange).toHaveBeenCalledWith("before XYZ after"); + }); +}); + +describe("detectMentionTrigger", () => { + it("returns the query when @ is at start of input", () => { + expect(detectMentionTrigger("@foo", 4)).toEqual({ triggerStart: 0, query: "foo" }); + }); + + it("returns the query when @ follows whitespace", () => { + expect(detectMentionTrigger("hello @bar", 10)).toEqual({ triggerStart: 6, query: "bar" }); + }); + + it("rejects @ inside a word (no preceding whitespace)", () => { + expect(detectMentionTrigger("foo@bar", 7)).toBeNull(); + }); + + it("rejects when whitespace appears between @ and cursor", () => { + expect(detectMentionTrigger("@foo bar", 8)).toBeNull(); + }); + + it("returns null when no @ before cursor", () => { + expect(detectMentionTrigger("plain text", 5)).toBeNull(); + }); +}); + +describe("filterItems — relevance ordering", () => { + function dummyItem(id: string, label: string): MentionItem { + return { id, kind: "prompt", label, onSelect: vi.fn() }; + } + + it("returns all items when query is empty", () => { + const items = [dummyItem("1", "a"), dummyItem("2", "b")]; + expect(filterItems(items, "")).toHaveLength(2); + }); + + it("orders prefix matches before contains matches", () => { + const items = [dummyItem("contains", "abc-foo"), dummyItem("prefix", "foo-bar")]; + const out = filterItems(items, "foo"); + expect(out.map((i) => i.id)).toEqual(["prefix", "contains"]); + }); + + it("returns built-in and user prompts together", () => { + const items = [dummyItem("builtin", "review"), dummyItem("user", "review-mine")]; + const out = filterItems(items, "review"); + expect(out).toHaveLength(2); + }); +}); diff --git a/apps/web/hooks/use-inline-mention.ts b/apps/web/hooks/use-inline-mention.ts index 91742a7e25..c7de00659e 100644 --- a/apps/web/hooks/use-inline-mention.ts +++ b/apps/web/hooks/use-inline-mention.ts @@ -48,7 +48,8 @@ function isValidMentionTrigger(text: string, pos: number): boolean { return charBefore === " " || charBefore === "\n" || charBefore === "\t"; } -function filterItems(items: MentionItem[], query: string): MentionItem[] { +/** Exported for tests. */ +export function filterItems(items: MentionItem[], query: string): MentionItem[] { if (!query) return items; const lowerQuery = query.toLowerCase(); @@ -106,9 +107,21 @@ function makePlanItem(onPlanSelect: () => void): MentionItem { }; } -/** Build a prompt mention item that adds prompt to context store instead of inserting content. */ -function makePromptItem( +export type PromptInsertMode = "context" | "inline"; + +/** + * Build a prompt mention item. + * + * - `"context"` (default): delete the `@query` text and notify `onPromptSelect` + * so the caller can attach the prompt as context (used by chat). + * - `"inline"`: replace the `@query` text with the prompt's full content + * (used by task-create where there is no context store yet). + * + * Exported for unit tests. + */ +export function makePromptItem( prompt: { id: string; name: string; content: string }, + mode: PromptInsertMode, onPromptSelect?: (id: string, name: string) => void, ): MentionItem { return { @@ -119,6 +132,16 @@ function makePromptItem( prompt.content.length > 100 ? prompt.content.slice(0, 100) + "..." : prompt.content, onSelect: (input, value, triggerStart, onChange) => { const cursorPos = input.getSelectionStart(); + if (mode === "inline") { + const insertion = prompt.content; + onChange(value.substring(0, triggerStart) + insertion + value.substring(cursorPos)); + const caret = triggerStart + insertion.length; + requestAnimationFrame(() => { + input.setSelectionRange(caret, caret); + input.focus(); + }); + return; + } onChange(value.substring(0, triggerStart) + value.substring(cursorPos)); onPromptSelect?.(prompt.id, prompt.name); requestAnimationFrame(() => { @@ -139,8 +162,8 @@ function clearMentionState( setQuery(""); } -/** Detect an @-trigger in text before cursor and return the query, or null if none. */ -function detectMentionTrigger( +/** Detect an @-trigger in text before cursor and return the query, or null if none. Exported for tests. */ +export function detectMentionTrigger( text: string, cursorPos: number, ): { triggerStart: number; query: string } | null { @@ -160,6 +183,8 @@ export interface UseInlineMentionParams { onPlanSelect?: () => void; onFileSelect?: (path: string, name: string) => void; onPromptSelect?: (id: string, name: string) => void; + /** How selecting a prompt mutates the input. Defaults to `"context"`. */ + promptInsertMode?: PromptInsertMode; } function useFileSearch(sessionId: string | null | undefined, isOpen: boolean, query: string) { @@ -264,6 +289,7 @@ type MentionItemsOptions = { onPlanSelect?: () => void; onFileSelect?: (path: string, name: string) => void; onPromptSelect?: (id: string, name: string) => void; + promptInsertMode: PromptInsertMode; }; function useMentionItems({ @@ -273,6 +299,7 @@ function useMentionItems({ onPlanSelect, onFileSelect, onPromptSelect, + promptInsertMode, }: MentionItemsOptions) { const planItem = useMemo((): MentionItem | null => { if (!onPlanSelect) return null; @@ -280,8 +307,8 @@ function useMentionItems({ }, [onPlanSelect]); const promptItems = useMemo( - () => prompts.map((prompt) => makePromptItem(prompt, onPromptSelect)), - [prompts, onPromptSelect], + () => prompts.map((prompt) => makePromptItem(prompt, promptInsertMode, onPromptSelect)), + [prompts, promptInsertMode, onPromptSelect], ); const fileItems = useMemo( @@ -305,6 +332,7 @@ export function useInlineMention({ onPlanSelect, onFileSelect, onPromptSelect, + promptInsertMode = "context", }: UseInlineMentionParams) { const [isOpen, setIsOpen] = useState(false); const [position, setPosition] = useState(null); @@ -321,6 +349,7 @@ export function useInlineMention({ onPlanSelect, onFileSelect, onPromptSelect, + promptInsertMode, }); /* eslint-disable react-hooks/set-state-in-effect */ diff --git a/apps/web/hooks/use-task-create-prompt-mention.ts b/apps/web/hooks/use-task-create-prompt-mention.ts new file mode 100644 index 0000000000..b0e77be94f --- /dev/null +++ b/apps/web/hooks/use-task-create-prompt-mention.ts @@ -0,0 +1,60 @@ +"use client"; + +import { useLayoutEffect, useMemo, useRef } from "react"; +import { useInlineMention } from "@/hooks/use-inline-mention"; +import { measureCaretRect } from "@/lib/utils/caret-position"; +import type { RichTextInputHandle } from "@/components/task/chat/rich-text-input"; + +type Params = { + textareaRef: React.RefObject; + value: string; + onChange: (newValue: string) => void; +}; + +/** + * Wires the `useInlineMention` autocomplete hook to a plain `