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>
144 lines
4.1 KiB
TypeScript
144 lines
4.1 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
forwardRef,
|
|
useRef,
|
|
useImperativeHandle,
|
|
type KeyboardEvent,
|
|
type ChangeEvent,
|
|
useCallback,
|
|
} from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import { measureCaretRect } from "@/lib/utils/caret-position";
|
|
|
|
function isSubmitKeyPress(
|
|
event: KeyboardEvent<HTMLTextAreaElement>,
|
|
submitKey: "enter" | "cmd_enter",
|
|
): boolean {
|
|
if (submitKey === "enter") {
|
|
return event.key === "Enter" && !event.shiftKey && !event.metaKey && !event.ctrlKey;
|
|
}
|
|
return event.key === "Enter" && (event.metaKey || event.ctrlKey);
|
|
}
|
|
|
|
export type RichTextInputHandle = {
|
|
focus: () => void;
|
|
blur: () => void;
|
|
getSelectionStart: () => number;
|
|
getSelectionEnd: () => number;
|
|
setSelectionRange: (start: number, end: number) => void;
|
|
getCaretRect: () => DOMRect | null;
|
|
getValue: () => string;
|
|
setValue: (value: string) => void;
|
|
insertText: (text: string, from: number, to: number) => void;
|
|
getTextareaElement: () => HTMLTextAreaElement | null;
|
|
};
|
|
|
|
type RichTextInputProps = {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
onKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
onSubmit?: () => void;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
planModeEnabled?: boolean;
|
|
submitKey?: "enter" | "cmd_enter";
|
|
onFocus?: () => void;
|
|
onBlur?: () => void;
|
|
};
|
|
|
|
export const RichTextInput = forwardRef<RichTextInputHandle, RichTextInputProps>(
|
|
function RichTextInput(
|
|
{
|
|
value,
|
|
onChange,
|
|
onKeyDown,
|
|
onSubmit,
|
|
placeholder,
|
|
disabled = false,
|
|
className,
|
|
planModeEnabled = false,
|
|
submitKey = "cmd_enter",
|
|
onFocus,
|
|
onBlur,
|
|
},
|
|
ref,
|
|
) {
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const getCaretRect = useCallback((): DOMRect | null => {
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) return null;
|
|
return measureCaretRect(textarea, value);
|
|
}, [value]);
|
|
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
focus: () => textareaRef.current?.focus(),
|
|
blur: () => textareaRef.current?.blur(),
|
|
getSelectionStart: () => textareaRef.current?.selectionStart ?? 0,
|
|
getSelectionEnd: () => textareaRef.current?.selectionEnd ?? 0,
|
|
setSelectionRange: (start: number, end: number) => {
|
|
textareaRef.current?.setSelectionRange(start, end);
|
|
},
|
|
getCaretRect,
|
|
getValue: () => textareaRef.current?.value ?? "",
|
|
setValue: (newValue: string) => onChange(newValue),
|
|
insertText: (text: string, from: number, to: number) => {
|
|
const newValue = value.substring(0, from) + text + value.substring(to);
|
|
onChange(newValue);
|
|
requestAnimationFrame(() => {
|
|
textareaRef.current?.setSelectionRange(from + text.length, from + text.length);
|
|
});
|
|
},
|
|
getTextareaElement: () => textareaRef.current,
|
|
}),
|
|
[getCaretRect, onChange, value],
|
|
);
|
|
|
|
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
|
|
onChange(event.target.value);
|
|
};
|
|
|
|
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (onKeyDown) {
|
|
onKeyDown(event);
|
|
if (event.defaultPrevented) return;
|
|
}
|
|
const isSubmitKey = isSubmitKeyPress(event, submitKey);
|
|
if (disabled) {
|
|
if (isSubmitKey) event.preventDefault();
|
|
return;
|
|
}
|
|
if (isSubmitKey) {
|
|
event.preventDefault();
|
|
onSubmit?.();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={value}
|
|
onChange={handleChange}
|
|
onKeyDown={handleKeyDown}
|
|
onFocus={onFocus}
|
|
onBlur={onBlur}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
className={cn(
|
|
"w-full h-full resize-none bg-transparent px-2 py-2 overflow-y-auto",
|
|
"text-sm leading-relaxed",
|
|
"placeholder:text-muted-foreground",
|
|
"focus:outline-none",
|
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
planModeEnabled && "border-primary/40",
|
|
className,
|
|
)}
|
|
/>
|
|
);
|
|
},
|
|
);
|