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>
170 lines
4.8 KiB
TypeScript
170 lines
4.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, type ReactNode } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const MENU_HEIGHT = 280;
|
|
const MENU_MAX_WIDTH = 700;
|
|
|
|
export type PopupMenuProps = {
|
|
isOpen: boolean;
|
|
position: { x: number; y: number } | null;
|
|
/** Alternative to position: a function returning a DOMRect (from TipTap suggestion) */
|
|
clientRect?: (() => DOMRect | null) | null;
|
|
title: string;
|
|
selectedIndex: number;
|
|
onClose: () => void;
|
|
children: ReactNode;
|
|
emptyState?: ReactNode;
|
|
hasItems?: boolean;
|
|
/** 'above' (default) positions bottom edge above cursor; 'below' positions top edge below cursor. */
|
|
placement?: "above" | "below";
|
|
};
|
|
|
|
export function PopupMenu({
|
|
isOpen,
|
|
position,
|
|
clientRect: clientRectFn,
|
|
title,
|
|
onClose,
|
|
children,
|
|
emptyState,
|
|
hasItems = true,
|
|
placement = "above",
|
|
}: PopupMenuProps) {
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Close on click outside
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [isOpen, onClose]);
|
|
|
|
// Resolve position from clientRect function or direct position
|
|
const resolvedPosition = (() => {
|
|
if (position) return position;
|
|
if (clientRectFn) {
|
|
const rect = clientRectFn();
|
|
if (rect) return { x: rect.left, y: placement === "below" ? rect.bottom : rect.top };
|
|
}
|
|
return null;
|
|
})();
|
|
|
|
if (!isOpen || !resolvedPosition) {
|
|
return null;
|
|
}
|
|
|
|
// 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 <body> 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: 60,
|
|
pointerEvents: "auto",
|
|
...(placement === "below"
|
|
? { top: resolvedPosition.y + 8 }
|
|
: { bottom: window.innerHeight - resolvedPosition.y + 8 }),
|
|
};
|
|
|
|
const menu = (
|
|
<div
|
|
ref={menuRef}
|
|
style={menuStyle}
|
|
className="overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10"
|
|
>
|
|
{/* Header */}
|
|
<div className="px-2 py-1.5 border-b border-border/50">
|
|
<span className="text-xs font-medium text-muted-foreground">{title}</span>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="overflow-y-auto py-1 scrollbar-thin" style={{ maxHeight: MENU_HEIGHT - 36 }}>
|
|
{hasItems ? children : emptyState}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Render via portal to escape any overflow containers
|
|
if (typeof document === "undefined") return null;
|
|
return createPortal(menu, document.body);
|
|
}
|
|
|
|
export type PopupMenuItemProps = {
|
|
icon: ReactNode;
|
|
label: string;
|
|
description?: string;
|
|
isSelected: boolean;
|
|
onClick: () => void;
|
|
onMouseEnter: () => void;
|
|
itemRef?: (el: HTMLButtonElement | null) => void;
|
|
};
|
|
|
|
export function PopupMenuItem({
|
|
icon,
|
|
label,
|
|
description,
|
|
isSelected,
|
|
onClick,
|
|
onMouseEnter,
|
|
itemRef,
|
|
}: PopupMenuItemProps) {
|
|
return (
|
|
<button
|
|
ref={itemRef}
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full cursor-pointer select-none items-center gap-3 rounded-[6px] mx-1 px-2 py-1.5 text-xs text-left",
|
|
"hover:bg-muted/50",
|
|
isSelected && "bg-muted/50",
|
|
)}
|
|
style={{ width: "calc(100% - 8px)" }}
|
|
onClick={onClick}
|
|
onMouseEnter={onMouseEnter}
|
|
>
|
|
<div className="h-4 w-4 text-muted-foreground shrink-0 flex items-center justify-center">
|
|
{icon}
|
|
</div>
|
|
<div className="flex items-baseline gap-2 min-w-0 flex-1">
|
|
<span className="shrink-0 font-medium">{label}</span>
|
|
{description && (
|
|
<span className="text-[11px] text-muted-foreground whitespace-nowrap">{description}</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// Hook for scroll-into-view behavior
|
|
export function useMenuItemRefs(selectedIndex: number) {
|
|
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
|
|
|
|
useEffect(() => {
|
|
const selectedItem = itemRefs.current.get(selectedIndex);
|
|
if (selectedItem) {
|
|
selectedItem.scrollIntoView({ block: "nearest" });
|
|
}
|
|
}, [selectedIndex]);
|
|
|
|
const setItemRef = (index: number) => (el: HTMLButtonElement | null) => {
|
|
if (el) {
|
|
itemRefs.current.set(index, el);
|
|
}
|
|
};
|
|
|
|
return { setItemRef };
|
|
}
|