Files
kandev/apps/web/components/task/chat/rich-text-input.tsx
T
João Salavisa a34ea719df feat(web): allow custom prompts in task creation input (#1155)
* 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>
2026-05-29 15:52:39 +01:00

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,
)}
/>
);
},
);