refactor: enhance inline mention editor and prompt picker UI with improved trigger handling and loading states

This commit is contained in:
Anish Sarkar 2026-04-14 00:10:57 +05:30
parent ec27807644
commit ae3d254a2c
3 changed files with 117 additions and 94 deletions

View file

@ -499,10 +499,14 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const empty = text.length === 0 && mentionedDocs.size === 0;
setIsEmpty(empty);
// Check for @ mentions
// Unified trigger scan: find the leftmost @ or / in the current word.
// Whichever trigger was typed first owns the token — the other character
// is treated as part of the query, not as a separate trigger.
const selection = window.getSelection();
let shouldTriggerMention = false;
let mentionQuery = "";
let shouldTriggerAction = false;
let actionQuery = "";
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
@ -512,63 +516,41 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const textContent = textNode.textContent || "";
const cursorPos = range.startOffset;
// Look for @ before cursor
let atIndex = -1;
let wordStart = 0;
for (let i = cursorPos - 1; i >= 0; i--) {
if (textContent[i] === "@") {
atIndex = i;
break;
}
// Stop if we hit a space (@ must be at word boundary)
if (textContent[i] === " " || textContent[i] === "\n") {
wordStart = i + 1;
break;
}
}
if (atIndex !== -1) {
const query = textContent.slice(atIndex + 1, cursorPos);
// Only trigger if query doesn't start with space
let triggerChar: "@" | "/" | null = null;
let triggerIndex = -1;
for (let i = wordStart; i < cursorPos; i++) {
if (textContent[i] === "@" || textContent[i] === "/") {
triggerChar = textContent[i] as "@" | "/";
triggerIndex = i;
break;
}
}
if (triggerChar === "@" && triggerIndex !== -1) {
const query = textContent.slice(triggerIndex + 1, cursorPos);
if (!query.startsWith(" ")) {
shouldTriggerMention = true;
mentionQuery = query;
}
}
}
}
// Check for / actions (same pattern as @)
let shouldTriggerAction = false;
let actionQuery = "";
if (!shouldTriggerMention && selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const textNode = range.startContainer;
if (textNode.nodeType === Node.TEXT_NODE) {
const textContent = textNode.textContent || "";
const cursorPos = range.startOffset;
let slashIndex = -1;
for (let i = cursorPos - 1; i >= 0; i--) {
if (textContent[i] === "/") {
slashIndex = i;
break;
}
if (textContent[i] === " " || textContent[i] === "\n") {
break;
}
}
if (
slashIndex !== -1 &&
(slashIndex === 0 ||
textContent[slashIndex - 1] === " " ||
textContent[slashIndex - 1] === "\n")
) {
const query = textContent.slice(slashIndex + 1, cursorPos);
if (!query.startsWith(" ")) {
shouldTriggerAction = true;
actionQuery = query;
} else if (triggerChar === "/" && triggerIndex !== -1) {
if (
triggerIndex === 0 ||
textContent[triggerIndex - 1] === " " ||
textContent[triggerIndex - 1] === "\n"
) {
const query = textContent.slice(triggerIndex + 1, cursorPos);
if (!query.startsWith(" ")) {
shouldTriggerAction = true;
actionQuery = query;
}
}
}
}

View file

@ -394,12 +394,6 @@ export const DocumentMentionPicker = forwardRef<
[selectableDocuments, highlightedIndex, handleSelectDocument, onDone]
);
// Hide popup when there are no documents to display (regardless of fetch state)
// Search continues in background; popup reappears when results arrive
if (!actualLoading && actualDocuments.length === 0) {
return null;
}
return (
<div
className="fixed shadow-2xl rounded-lg border border-border dark:border-white/5 overflow-hidden bg-popover dark:bg-neutral-900 flex flex-col w-[280px] sm:w-[320px] select-none"
@ -547,7 +541,11 @@ export const DocumentMentionPicker = forwardRef<
</div>
)}
</div>
) : null}
) : (
<div className="py-1 px-2">
<p className="px-3 py-2 text-xs text-muted-foreground">No matching documents</p>
</div>
)}
</div>
</div>
);

View file

@ -15,7 +15,7 @@ import {
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Spinner } from "@/components/ui/spinner";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
export interface PromptPickerRef {
@ -60,13 +60,21 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
}
}
const createPromptIndex = filtered.length;
const totalItems = filtered.length + 1;
const handleSelect = useCallback(
(index: number) => {
if (index === createPromptIndex) {
onDone();
setUserSettingsDialog({ open: true, initialTab: "prompts" });
return;
}
const action = filtered[index];
if (!action) return;
onSelect({ name: action.name, prompt: action.prompt, mode: action.mode });
},
[filtered, onSelect]
[filtered, onSelect, createPromptIndex, onDone, setUserSettingsDialog]
);
useEffect(() => {
@ -93,69 +101,104 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
() => ({
selectHighlighted: () => handleSelect(highlightedIndex),
moveUp: () => {
if (filtered.length === 0) return;
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1));
},
moveDown: () => {
if (filtered.length === 0) return;
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
setHighlightedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0));
},
}),
[filtered.length, highlightedIndex, handleSelect]
[totalItems, highlightedIndex, handleSelect]
);
return (
<div
className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden"
style={containerStyle}
className="fixed shadow-2xl rounded-lg border border-border dark:border-white/5 overflow-hidden bg-popover dark:bg-neutral-900 flex flex-col w-[280px] sm:w-[320px] select-none"
style={{
zIndex: 9999,
...containerStyle,
}}
>
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
<div ref={scrollContainerRef} className="max-h-[180px] sm:max-h-[280px] overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-3">
<Spinner className="size-4" />
<div className="py-1 px-2">
<div className="px-3 py-2">
<Skeleton className="h-[16px] w-24" />
</div>
{["a", "b", "c", "d", "e"].map((id, i) => (
<div
key={id}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left rounded-md",
i >= 3 && "hidden sm:flex"
)}
>
<span className="shrink-0">
<Skeleton className="h-4 w-4" />
</span>
<span className="flex-1 text-sm">
<Skeleton className="h-[20px]" style={{ width: `${60 + ((i * 7) % 30)}%` }} />
</span>
</div>
))}
</div>
) : isError ? (
<p className="px-3 py-2 text-xs text-destructive">Failed to load prompts</p>
<div className="py-1 px-2">
<p className="px-3 py-2 text-xs text-destructive">Failed to load prompts</p>
</div>
) : filtered.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p>
<div className="py-1 px-2">
<p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p>
</div>
) : (
filtered.map((action, index) => (
<div className="py-1 px-2">
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
Saved Prompts
</div>
{filtered.map((action, index) => (
<button
key={action.id}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
}}
type="button"
onClick={() => handleSelect(index)}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors rounded-md cursor-pointer",
index === highlightedIndex && "bg-accent"
)}
>
<span className="shrink-0 text-muted-foreground">
<Zap className="size-4" />
</span>
<span className="flex-1 text-sm truncate">{action.name}</span>
</button>
))}
<div className="mx-2 my-1 border-t border-border dark:border-white/5" />
<button
key={action.id}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
if (el) itemRefs.current.set(createPromptIndex, el);
else itemRefs.current.delete(createPromptIndex);
}}
type="button"
onClick={() => handleSelect(index)}
onMouseEnter={() => setHighlightedIndex(index)}
onClick={() => handleSelect(createPromptIndex)}
onMouseEnter={() => setHighlightedIndex(createPromptIndex)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
"w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors rounded-md cursor-pointer text-muted-foreground",
highlightedIndex === createPromptIndex ? "bg-accent text-foreground" : "hover:text-foreground hover:bg-accent/50"
)}
>
<span className="text-muted-foreground">
<Zap className="size-3.5" />
<span className="shrink-0">
<Plus className="size-4" />
</span>
<span className="truncate">{action.name}</span>
<span>Create prompt</span>
</button>
))
</div>
)}
<div className="my-1 h-px bg-border mx-2" />
<button
type="button"
onClick={() => {
onDone();
setUserSettingsDialog({ open: true, initialTab: "prompts" });
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 cursor-pointer"
>
<Plus className="size-3.5" />
<span>Create prompt</span>
</button>
</div>
</div>
);