diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index 73865f1b3ec..1773f242283 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -82,6 +82,7 @@ interface ResourceFolderTreeItemsProps { */ folderType?: MothershipResourceType onSelect: (resource: MothershipResource) => void + subContentClassName?: string } /** Renders a {@link buildResourceFolderTree} result as nested dropdown submenus. */ @@ -90,6 +91,7 @@ export function ResourceFolderTreeItems({ type, folderType, onSelect, + subContentClassName, }: ResourceFolderTreeItemsProps) { const config = getResourceConfig(type) return ( @@ -108,7 +110,7 @@ export function ResourceFolderTreeItems({ - + {folderType && ( onSelect({ type: folderType, id: node.id, title: node.name })} @@ -122,6 +124,7 @@ export function ResourceFolderTreeItems({ type={type} folderType={folderType} onSelect={onSelect} + subContentClassName={subContentClassName} /> @@ -275,6 +278,7 @@ export function ResourceMenuSections({ type={section.type} folderType={section.folderType} onSelect={onSelect} + subContentClassName={subContentClassName} /> ) : ( items.map((item) => ( @@ -401,6 +405,7 @@ interface WorkspaceResourceMenuContentProps { /** Offers every folder as an attachable entry, as chat does. */ selectFolders?: boolean onSelect: (resource: MothershipResource) => void + subContentClassName?: string } function WorkspaceResourceMenuContent({ @@ -410,6 +415,7 @@ function WorkspaceResourceMenuContent({ searchable = true, selectFolders, onSelect, + subContentClassName, }: WorkspaceResourceMenuContentProps) { const { groups, structureFolders, isHydrating } = useAvailableResources(workspaceId, { enabled, @@ -425,7 +431,12 @@ function WorkspaceResourceMenuContent({ /** Lists fill in as they load, so a trailing row keeps a loading workspace from reading as empty. */ const menu = ( <> - + {isHydrating && Loading resources} ) @@ -449,6 +460,7 @@ interface WorkspaceResourceSubmenuProps { * offers its folder, for pickers that can attach a whole workspace. */ onSelectWorkspace?: (workspace: Pick) => void + subContentClassName?: string } /** @@ -461,6 +473,7 @@ export function WorkspaceResourceSubmenu({ selectFolders, onSelect, onSelectWorkspace, + subContentClassName, }: WorkspaceResourceSubmenuProps) { const [open, setOpen] = useState(false) const icon = ( @@ -472,7 +485,9 @@ export function WorkspaceResourceSubmenu({ {icon} - + {onSelectWorkspace && ( onSelectWorkspace(workspace)}> {icon} @@ -486,6 +501,7 @@ export function WorkspaceResourceSubmenu({ searchable={false} selectFolders={selectFolders} onSelect={onSelect} + subContentClassName={subContentClassName} /> diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx index 6eb493cc479..3759f6e3d81 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx @@ -5,11 +5,15 @@ import { cn, DropdownMenu, DropdownMenuContent, + DropdownMenuItemLabel, DropdownMenuLabel, DropdownMenuSearchInput, DropdownMenuTrigger, dropdownMenuRowClass, + OverflowText, } from '@sim/emcn' +import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { getWorkspaceInitial } from '@/lib/workspaces/initials' import { ResourceMenuSections, resourceFromItem, @@ -30,6 +34,7 @@ import { import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants' import { buildMentionPreview, + type ResourceMentionCandidate, resourceMentionMatches, withBrowserTabMentions, withFolderMentions, @@ -39,7 +44,7 @@ import type { MothershipResource, MothershipResourceType, } from '@/app/workspace/[workspaceId]/home/types' -import { useOrderedWorkspacesQuery } from '@/hooks/queries/workspace' +import { useOrderedWorkspacesQuery, type Workspace } from '@/hooks/queries/workspace' import { useSettledTerminalCommands } from '@/hooks/use-settled-terminal-commands' import { useBrowserSessionStore } from '@/stores/browser-session/store' import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store' @@ -51,6 +56,15 @@ import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store' * autocomplete. ~10 rows is enough to show several families at once. */ const MENTION_MAX_HEIGHT_CLASS = 'max-h-[min(280px,var(--radix-popper-available-height,280px))]' +const RESOURCE_MENU_WIDTH_CLASS = 'w-[360px] min-w-0 max-w-[min(360px,calc(100vw-16px))]' + +type MentionCandidate = + | ResourceMentionCandidate + | { type: 'workspace'; item: Pick } + +function candidateKey({ type, item }: MentionCandidate): string { + return `${type}:${'workspaceId' in item ? item.workspaceId : ''}:${item.id}` +} /** * Resource types that are only offered via `@`-mention autocomplete and hidden @@ -122,7 +136,7 @@ export const PlusMenuDropdown = React.memo( const [isMention, setIsMention] = useState(false) const [search, setSearch] = useState('') const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null) - const [activeIndex, setActiveIndex] = useState(0) + const [activeItem, setActiveItem] = useState<{ query: string; key: string } | null>(null) const searchRef = useRef(null) const contentRef = useRef(null) const browserTabs = useBrowserSessionStore((state) => { @@ -141,11 +155,15 @@ export const PlusMenuDropdown = React.memo( enabled: inventoryEnabled, includeFolderMentions: true, }) - const { data: allWorkspaces = [], isPending: workspacesPending } = useOrderedWorkspacesQuery( + const { data: allWorkspaces, isPending: workspacesPending } = useOrderedWorkspacesQuery( Boolean(organizationId) && inventoryEnabled ) - const workspaces = allWorkspaces.filter( - (workspace) => workspace.organizationId === organizationId + const workspaces = useMemo( + () => + organizationId + ? (allWorkspaces ?? []).filter((workspace) => workspace.organizationId === organizationId) + : [], + [allWorkspaces, organizationId] ) const [inventories, setInventories] = useState>({}) const receiveInventory = useCallback((workspaceId: string, inventory: AvailableResources) => { @@ -170,7 +188,7 @@ export const PlusMenuDropdown = React.memo( setIsMention(!!options?.mention) setOpen(true) setSearch('') - setActiveIndex(0) + setActiveItem(null) }, [] ) @@ -211,20 +229,45 @@ export const PlusMenuDropdown = React.memo( selectFolders: true, }) - const filteredItems = useMemo(() => { - const rawQuery = isMention ? (mentionQuery ?? '') : search - const q = rawQuery.toLowerCase().trim() + const query = isMention ? (mentionQuery ?? '') : search + const filteredItems = useMemo((): MentionCandidate[] | null => { + const q = query.toLowerCase().trim() if (!isMention && !q) return null - if (isMention && !q) { - return buildMentionPreview( - visibleResources, - (type) => getResourceConfig(type).mentionPreviewLimit ?? MENTION_PREVIEW_DEFAULT_LIMIT - ) - } - return visibleResources.flatMap(({ type, items }) => - items.filter((item) => resourceMentionMatches(item, q)).map((item) => ({ type, item })) + const workspaceItems: MentionCandidate[] = ( + q ? workspaces : workspaces.slice(0, MENTION_PREVIEW_DEFAULT_LIMIT) ) - }, [isMention, mentionQuery, search, visibleResources]) + .filter((workspace) => workspace.name.toLowerCase().includes(q)) + .map((item) => ({ type: 'workspace', item })) + const resourceItems = q + ? visibleResources.flatMap(({ type, items }) => + items.filter((item) => resourceMentionMatches(item, q)).map((item) => ({ type, item })) + ) + : buildMentionPreview( + visibleResources, + (type) => getResourceConfig(type).mentionPreviewLimit ?? MENTION_PREVIEW_DEFAULT_LIMIT + ) + return [...workspaceItems, ...resourceItems] + }, [isMention, query, visibleResources, workspaces]) + + const activeIndex = Math.max( + 0, + filteredItems?.findIndex( + (candidate) => activeItem?.query === query && candidateKey(candidate) === activeItem.key + ) ?? -1 + ) + const activeKey = filteredItems?.[activeIndex] ? candidateKey(filteredItems[activeIndex]) : null + if (activeKey !== null && (activeItem?.query !== query || activeItem.key !== activeKey)) { + setActiveItem({ query, key: activeKey }) + } else if (activeItem !== null && activeItem.query !== query) { + setActiveItem(null) + } + + const highlightIndex = (index: number) => { + const candidate = filteredItems?.[index] + if (candidate) setActiveItem({ query, key: candidateKey(candidate) }) + } + const highlightIndexRef = useRef(highlightIndex) + highlightIndexRef.current = highlightIndex const filteredItemsRef = useRef(filteredItems) filteredItemsRef.current = filteredItems @@ -235,16 +278,10 @@ export const PlusMenuDropdown = React.memo( const isHydratingRef = useRef(isHydrating) isHydratingRef.current = isHydrating - // Reset highlight to the top whenever the mention query changes so the user always - // sees the best match selected as they type. - useEffect(() => { - if (isMention) setActiveIndex(0) - }, [isMention, mentionQuery]) - const closeAfterSelect = () => { setOpen(false) setSearch('') - setActiveIndex(0) + setActiveItem(null) } const handleSelect = (resource: MothershipResource) => { @@ -257,8 +294,12 @@ export const PlusMenuDropdown = React.memo( closeAfterSelect() } - const handleSelectRef = useRef(handleSelect) - handleSelectRef.current = handleSelect + const handleCandidateSelect = (candidate: MentionCandidate) => { + if (candidate.type === 'workspace') handleWorkspaceSelect(candidate.item) + else handleSelect(resourceFromItem(candidate.type, candidate.item)) + } + const handleSelectRef = useRef(handleCandidateSelect) + handleSelectRef.current = handleCandidateSelect React.useImperativeHandle( ref, @@ -268,18 +309,14 @@ export const PlusMenuDropdown = React.memo( moveActive: (delta: number) => { const items = filteredItemsRef.current if (!items || items.length === 0) return - setActiveIndex((i) => { - const next = i + delta - if (next < 0) return items.length - 1 - if (next >= items.length) return 0 - return next - }) + const next = activeIndexRef.current + delta + highlightIndexRef.current(next < 0 ? items.length - 1 : next >= items.length ? 0 : next) }, selectActive: () => { const items = filteredItemsRef.current const target = items?.length ? (items[activeIndexRef.current] ?? items[0]) : undefined if (!target) return isHydratingRef.current ? 'hydrating' : 'empty' - handleSelectRef.current(resourceFromItem(target.type, target.item)) + handleSelectRef.current(target) return 'selected' }, }), @@ -311,14 +348,14 @@ export const PlusMenuDropdown = React.memo( if (filteredItems.length === 0) return if (e.key === 'ArrowDown') { e.preventDefault() - setActiveIndex((i) => Math.min(i + 1, filteredItems.length - 1)) + highlightIndex(Math.min(activeIndex + 1, filteredItems.length - 1)) } else if (e.key === 'ArrowUp') { e.preventDefault() - setActiveIndex((i) => Math.max(i - 1, 0)) + highlightIndex(Math.max(activeIndex - 1, 0)) } else if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) { e.preventDefault() const target = filteredItems[activeIndex] ?? filteredItems[0] - if (target) handleSelect(resourceFromItem(target.type, target.item)) + if (target) handleCandidateSelect(target) } } @@ -343,7 +380,7 @@ export const PlusMenuDropdown = React.memo( if (!isOpen) { setSearch('') setAnchorPos(null) - setActiveIndex(0) + setActiveItem(null) onClose() } } @@ -392,10 +429,8 @@ export const PlusMenuDropdown = React.memo( collisionPadding={8} className={cn( 'flex flex-col overflow-hidden', - // Plus-click shows short fixed labels (Workflows, Tables, …) — let it size - // to its content via the emcn DropdownMenuContent default max-w. - // Mention mode renders resource names directly, so widen for breathing room. - isMention && `max-w-[min(300px,calc(100vw-32px))] ${MENTION_MAX_HEIGHT_CLASS}` + RESOURCE_MENU_WIDTH_CLASS, + isMention && MENTION_MAX_HEIGHT_CLASS )} onCloseAutoFocus={handleCloseAutoFocus} onOpenAutoFocus={handleOpenAutoFocus} @@ -408,7 +443,6 @@ export const PlusMenuDropdown = React.memo( value={search} onChange={(e) => { setSearch(e.target.value) - setActiveIndex(0) }} onKeyDown={handleSearchKeyDown} /> @@ -426,6 +460,7 @@ export const PlusMenuDropdown = React.memo( selectFolders onSelect={handleSelect} onSelectWorkspace={handleWorkspaceSelect} + subContentClassName={RESOURCE_MENU_WIDTH_CLASS} /> ))} {/* Plain buttons, not DropdownMenuItem: mount/unmount must not mutate Radix's menu Collection, or FocusScope restores focus to the content root. */} {filteredItems !== null && (filteredItems.length > 0 ? ( - filteredItems.map(({ type, item }, index) => { - const config = getResourceConfig(type) + filteredItems.map((candidate, index) => { + const { type, item } = candidate + const config = type === 'workspace' ? null : getResourceConfig(type) + const workspaceName = 'workspaceName' in item ? item.workspaceName : undefined const isActive = index === activeIndex /* Items arrive grouped by family (one group per type, ordered by RESOURCE_MENU_ORDER), so a type change marks a section boundary. @@ -450,15 +487,17 @@ export const PlusMenuDropdown = React.memo( therefore every keyboard path — indexing exactly what it did. */ const startsSection = index === 0 || filteredItems[index - 1]?.type !== type return ( - - {startsSection && {config.label}} + + {startsSection && ( + {config?.label ?? 'Workspaces'} + )} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx index b3f1316c96a..97b5d72f905 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx @@ -15,6 +15,7 @@ import { type UsePromptEditorProps, usePromptEditor, } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor' +import { getIntegrationMatcher } from '@/blocks/integration-matcher' import type { ChatContext } from '@/stores/panel' function selectionPayload(context: ChatContext, sourceWorkspaceId = 'ws-1'): string { @@ -74,6 +75,153 @@ function typeInto(textarea: HTMLTextAreaElement, value: string, caret = value.le textarea.dispatchEvent(new Event('input', { bubbles: true })) } +describe.each([{ workspaceId: 'ws-1' }, { workspaceId: '', organizationId: 'org-1' }])( + 'mention search in $workspaceId $organizationId', + (scope) => { + it.each([false, true])( + 'does not auto-attach an integration after searching (dismissed: %s)', + (dismissed) => { + const matcher = vi.mocked(getIntegrationMatcher) + matcher.mockReturnValue({ + regex: /Slack/gi, + byName: new Map([ + ['slack', { name: 'Slack', blockType: 'slack', icon: () => null, bgColor: '#fff' }], + ]), + }) + const { result, textarea, unmount } = renderPromptEditor(scope) + try { + for (const character of '@Slack') { + act(() => { + typeInto(textarea, textarea.value + character) + result().handleInputChange({ + target: textarea, + } as React.ChangeEvent) + }) + } + if (dismissed) act(() => result().handlePlusMenuClose()) + for (const character of ' roadmap') { + act(() => { + typeInto(textarea, textarea.value + character) + result().handleInputChange({ + target: textarea, + } as React.ChangeEvent) + }) + } + expect(result().getActiveContexts()).toEqual([]) + expect(result().mentionQuery).toBe(dismissed ? null : 'Slack roadmap') + } finally { + unmount() + matcher.mockReset() + } + } + ) + + it('keeps multiword and punctuated names searchable and replaces the whole query', () => { + const { result, textarea, unmount } = renderPromptEditor(scope) + try { + for (const value of [ + 'Find @Quarterly', + 'Find @Quarterly ', + 'Find @Quarterly plan (v2).md', + ]) { + act(() => { + typeInto(textarea, value) + result().handleInputChange({ + target: textarea, + } as React.ChangeEvent) + }) + expect(result().mentionQuery).toBe(value.slice('Find @'.length)) + } + act(() => + result().insertResource({ type: 'file', id: 'plan', title: 'Quarterly plan (v2).md' }) + ) + expect(result().getPlainValue()).toBe('Find @Quarterly plan (v2).md ') + expect(result().getActiveContexts()).toEqual([ + { kind: 'file', fileId: 'plan', label: 'Quarterly plan (v2).md' }, + ]) + } finally { + unmount() + } + }) + + it('keeps a dismissed search closed while typing, but allows a new trigger', () => { + const { result, textarea, unmount } = renderPromptEditor(scope) + const input = (value: string) => { + act(() => { + typeInto(textarea, value) + result().handleInputChange({ target: textarea } as React.ChangeEvent) + }) + } + try { + input('@Quarterly') + act(() => result().handlePlusMenuClose()) + input('@Quarterlyplan') + expect(result().mentionQuery).toBeNull() + input('@Quarterly plan') + expect(result().mentionQuery).toBeNull() + input('@Quarterly plan @Roadmap') + expect(result().mentionQuery).toBe('Roadmap') + } finally { + unmount() + } + }) + + it.each(['selection', 'programmatic'])( + 'opens a fresh search when replacing a dismissed trigger via %s', + (replacement) => { + const { result, textarea, unmount } = renderPromptEditor(scope) + const input = (value: string) => { + act(() => { + typeInto(textarea, value) + result().handleInputChange({ + target: textarea, + } as React.ChangeEvent) + }) + } + try { + input('@Quarterly') + act(() => result().handlePlusMenuClose()) + if (replacement === 'selection') { + act(() => { + textarea.setSelectionRange(0, textarea.value.length) + result().handleSelectAdjust() + }) + expect(result().mentionQuery).toBeNull() + input('@Roadmap') + } else { + act(() => result().setValue('@Roadmap', { chipify: false })) + act(() => { + typeInto(textarea, result().getValue()) + result().handleSelectAdjust() + }) + } + expect(result().mentionQuery).toBe('Roadmap') + } finally { + unmount() + } + } + ) + + it.each(['@ ', '@ name', '@Quarterly\n', 'person@example.com'])( + 'does not search across a dismissed boundary in %j', + (value) => { + const { result, textarea, unmount } = renderPromptEditor(scope) + try { + act(() => { + typeInto(textarea, value) + result().handleInputChange({ + target: textarea, + } as React.ChangeEvent) + }) + expect(result().mentionQuery).toBeNull() + } finally { + unmount() + } + } + ) + } +) + describe('usePromptEditor context insertion', () => { it('leaves a cross-workspace selection to the ordinary plain-text paste path', () => { const context = { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index a3e58fff4c7..b4ed623c6d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -217,11 +217,10 @@ export function usePromptEditor({ /** * Start offset of a mention/slash token most recently dismissed by the user - * (outside click or Escape) without a following keystroke — suppresses a - * single reopen of the menu for that exact token when the caret's own - * selection-change handler runs immediately after. + * (outside click or Escape). Mention dismissal lasts until the caret leaves + * that query; slash dismissal lasts until the next edit. */ - const dismissedMentionStartRef = useRef(null) + const dismissedMentionRef = useRef<{ start: number; triggerSelected: boolean } | null>(null) const dismissedSlashStartRef = useRef(null) const contextManagement = useContextManagement({ @@ -338,6 +337,16 @@ export function usePromptEditor({ */ const setValue = useCallback((text: string, options?: { chipify?: boolean }) => { const next = options?.chipify === false ? text : applyAutoMentionsRef.current(text) + atInsertPosRef.current = null + pendingCursorRef.current = null + mentionRangeRef.current = null + dismissedMentionRef.current = null + setMentionQuery(null) + plusMenuRef.current?.close() + slashRangeRef.current = null + dismissedSlashStartRef.current = null + setSlashQuery(null) + skillsMenuRef.current?.close() valueRef.current = next setValueState(next) }, []) @@ -411,7 +420,7 @@ export function usePromptEditor({ atInsertPosRef.current = null mentionRangeRef.current = null setMentionQuery(null) - dismissedMentionStartRef.current = null + dismissedMentionRef.current = null plusMenuRef.current?.close() slashRangeRef.current = null setSlashQuery(null) @@ -439,7 +448,7 @@ export function usePromptEditor({ plusMenuRef.current?.close() mentionRangeRef.current = null setMentionQuery(null) - dismissedMentionStartRef.current = null + dismissedMentionRef.current = null skillsMenuRef.current?.close() slashRangeRef.current = null setSlashQuery(null) @@ -493,7 +502,7 @@ export function usePromptEditor({ atInsertPosRef.current = newPos mentionRangeRef.current = null setMentionQuery(null) - dismissedMentionStartRef.current = null + dismissedMentionRef.current = null setValueState(newValue) } @@ -693,7 +702,9 @@ export function usePromptEditor({ * `onOpenChange` and never call this. */ const handlePlusMenuClose = useCallback(() => { - dismissedMentionStartRef.current = mentionRangeRef.current?.start ?? null + dismissedMentionRef.current = mentionRangeRef.current + ? { start: mentionRangeRef.current.start, triggerSelected: false } + : null atInsertPosRef.current = null mentionRangeRef.current = null setMentionQuery(null) @@ -708,30 +719,32 @@ export function usePromptEditor({ const syncMentionState = useCallback( (textarea: HTMLTextAreaElement, text: string, caret: number) => { if (!contextsEnabledRef.current) return + const dismissed = dismissedMentionRef.current + if (dismissed) { + dismissed.triggerSelected = + textarea.selectionStart <= dismissed.start && textarea.selectionEnd > dismissed.start + if (dismissed.triggerSelected) return + } const active = getActiveMentionAtRef.current(caret, text) - // Any word-boundary character inside the query — whitespace, sentence - // punctuation, or brackets — dismisses the menu. The mention token - // is "complete" the moment the user types a non-word character, so - // there's nothing more to query. Mirrors the boundary set the - // integration auto-detector uses for symmetry. - const isOpenable = active && !/[\s.,;:!?(){}[\]"'`/\\<>]/.test(active.query) + const isOpenable = active && !/[\r\n]/.test(active.query) if (!isOpenable) { if (mentionRangeRef.current !== null) { mentionRangeRef.current = null setMentionQuery(null) plusMenuRef.current?.close() } - dismissedMentionStartRef.current = null + dismissedMentionRef.current = null return } - if (active.start === dismissedMentionStartRef.current) { + if (active.start === dismissedMentionRef.current?.start) { if (mentionRangeRef.current !== null) { mentionRangeRef.current = null setMentionQuery(null) } return } + dismissedMentionRef.current = null const wasActive = mentionRangeRef.current !== null mentionRangeRef.current = { start: active.start, end: active.end } @@ -825,9 +838,16 @@ export function usePromptEditor({ pendingCursorRef.current = null const previousValue = valueRef.current const nextValue = e.target.value + const hasMentionQuery = + mentionRangeRef.current !== null || dismissedMentionRef.current !== null + if (dismissedMentionRef.current?.triggerSelected) dismissedMentionRef.current = null let finalValue = nextValue - if (contextsEnabledRef.current && nextValue.length === previousValue.length + 1) { + if ( + contextsEnabledRef.current && + !hasMentionQuery && + nextValue.length === previousValue.length + 1 + ) { // Single-char keystroke — synchronous, boundary-triggered. finalValue = integrationAutoMention.processChange({ textarea: e.target, @@ -843,6 +863,7 @@ export function usePromptEditor({ nextValue: finalValue, }) } else if ( + !hasMentionQuery && nextValue.length > previousValue.length + 1 && nextValue.length <= PASTE_RENDER_THRESHOLDS.ENHANCED_TEXT_CHARACTERS ) { @@ -867,7 +888,6 @@ export function usePromptEditor({ const caret = e.target.selectionStart ?? finalValue.length valueRef.current = finalValue setValueState(finalValue) - dismissedMentionStartRef.current = null dismissedSlashStartRef.current = null syncMentionState(e.target, finalValue, caret) syncSlashState(e.target, finalValue, caret)