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)