a34ea719df
* 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 <noreply@anthropic.com> * 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 @<name>. - 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
155 lines
5.2 KiB
TypeScript
155 lines
5.2 KiB
TypeScript
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<void>((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);
|
|
});
|
|
});
|