From 80c8c1bc5399bc81f115d757d83442c6fad26198 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 1 Oct 2026 00:36:50 -0700 Subject: [PATCH 1/3] fix(chat): keep mention searches open and align resource menus --- .../add-resource-dropdown.tsx | 16 ++- .../plus-menu-dropdown/plus-menu-dropdown.tsx | 107 +++++++++++------ .../prompt-editor/use-prompt-editor.test.tsx | 112 ++++++++++++++++++ .../prompt-editor/use-prompt-editor.ts | 23 ++-- 4 files changed, 211 insertions(+), 47 deletions(-) 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..6a739b82595 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 @@ -401,6 +401,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 +411,7 @@ function WorkspaceResourceMenuContent({ searchable = true, selectFolders, onSelect, + subContentClassName, }: WorkspaceResourceMenuContentProps) { const { groups, structureFolders, isHydrating } = useAvailableResources(workspaceId, { enabled, @@ -425,7 +427,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 +456,7 @@ interface WorkspaceResourceSubmenuProps { * offers its folder, for pickers that can attach a whole workspace. */ onSelectWorkspace?: (workspace: Pick) => void + subContentClassName?: string } /** @@ -461,6 +469,7 @@ export function WorkspaceResourceSubmenu({ selectFolders, onSelect, onSelectWorkspace, + subContentClassName, }: WorkspaceResourceSubmenuProps) { const [open, setOpen] = useState(false) const icon = ( @@ -472,7 +481,9 @@ export function WorkspaceResourceSubmenu({ {icon} - + {onSelectWorkspace && ( onSelectWorkspace(workspace)}> {icon} @@ -486,6 +497,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..e0abf1e9ba6 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,11 @@ 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 } /** * Resource types that are only offered via `@`-mention autocomplete and hidden @@ -141,11 +151,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) => { @@ -211,20 +225,23 @@ export const PlusMenuDropdown = React.memo( selectFolders: true, }) - const filteredItems = useMemo(() => { + const filteredItems = useMemo((): MentionCandidate[] | null => { const rawQuery = isMention ? (mentionQuery ?? '') : search const q = rawQuery.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 })) - ) - }, [isMention, mentionQuery, search, visibleResources]) + const workspaceItems: MentionCandidate[] = workspaces + .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, mentionQuery, search, visibleResources, workspaces]) const filteredItemsRef = useRef(filteredItems) filteredItemsRef.current = filteredItems @@ -257,8 +274,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, @@ -279,7 +300,7 @@ export const PlusMenuDropdown = React.memo( 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' }, }), @@ -318,7 +339,7 @@ export const PlusMenuDropdown = React.memo( } 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) } } @@ -392,10 +413,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} @@ -426,6 +445,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 +472,19 @@ 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..a199af6e6f7 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,117 @@ 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(['@ ', '@ 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..53be59ed544 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,9 +217,8 @@ 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 dismissedSlashStartRef = useRef(null) @@ -709,12 +708,7 @@ export function usePromptEditor({ (textarea: HTMLTextAreaElement, text: string, caret: number) => { if (!contextsEnabledRef.current) 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 @@ -732,6 +726,7 @@ export function usePromptEditor({ } return } + dismissedMentionStartRef.current = null const wasActive = mentionRangeRef.current !== null mentionRangeRef.current = { start: active.start, end: active.end } @@ -825,9 +820,15 @@ export function usePromptEditor({ pendingCursorRef.current = null const previousValue = valueRef.current const nextValue = e.target.value + const hasMentionQuery = + mentionRangeRef.current !== null || dismissedMentionStartRef.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 +844,7 @@ export function usePromptEditor({ nextValue: finalValue, }) } else if ( + !hasMentionQuery && nextValue.length > previousValue.length + 1 && nextValue.length <= PASTE_RENDER_THRESHOLDS.ENHANCED_TEXT_CHARACTERS ) { @@ -867,7 +869,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) From 5c0dd0323e9c5ad19755e72d60c608901be14427 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 1 Oct 2026 00:51:35 -0700 Subject: [PATCH 2/3] fix(chat): preserve mention selection and nested menu sizing --- .../add-resource-dropdown.tsx | 6 +- .../plus-menu-dropdown/plus-menu-dropdown.tsx | 63 +++++++++++-------- .../prompt-editor/use-prompt-editor.test.tsx | 22 +++++++ .../prompt-editor/use-prompt-editor.ts | 8 +++ 4 files changed, 72 insertions(+), 27 deletions(-) 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 6a739b82595..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) => ( 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 e0abf1e9ba6..ea841590017 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 @@ -62,6 +62,10 @@ 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 * from the `+` browse menu. Integrations are searchable inline (e.g. typing @@ -132,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) => { @@ -184,7 +188,7 @@ export const PlusMenuDropdown = React.memo( setIsMention(!!options?.mention) setOpen(true) setSearch('') - setActiveIndex(0) + setActiveItem(null) }, [] ) @@ -225,9 +229,9 @@ export const PlusMenuDropdown = React.memo( selectFolders: true, }) + const query = isMention ? (mentionQuery ?? '') : search const filteredItems = useMemo((): MentionCandidate[] | null => { - const rawQuery = isMention ? (mentionQuery ?? '') : search - const q = rawQuery.toLowerCase().trim() + const q = query.toLowerCase().trim() if (!isMention && !q) return null const workspaceItems: MentionCandidate[] = workspaces .filter((workspace) => workspace.name.toLowerCase().includes(q)) @@ -241,7 +245,27 @@ export const PlusMenuDropdown = React.memo( (type) => getResourceConfig(type).mentionPreviewLimit ?? MENTION_PREVIEW_DEFAULT_LIMIT ) return [...workspaceItems, ...resourceItems] - }, [isMention, mentionQuery, search, visibleResources, workspaces]) + }, [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 @@ -252,16 +276,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) => { @@ -289,12 +307,8 @@ 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 @@ -332,10 +346,10 @@ 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] @@ -364,7 +378,7 @@ export const PlusMenuDropdown = React.memo( if (!isOpen) { setSearch('') setAnchorPos(null) - setActiveIndex(0) + setActiveItem(null) onClose() } } @@ -427,7 +441,6 @@ export const PlusMenuDropdown = React.memo( value={search} onChange={(e) => { setSearch(e.target.value) - setActiveIndex(0) }} onKeyDown={handleSearchKeyDown} /> @@ -472,9 +485,7 @@ 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 ?? 'Workspaces'} )} @@ -482,7 +493,7 @@ export const PlusMenuDropdown = React.memo( type='button' role='menuitem' data-filtered-idx={index} - onMouseEnter={() => setActiveIndex(index)} + onMouseEnter={() => highlightIndex(index)} onClick={() => { handleCandidateSelect(candidate) }} 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 a199af6e6f7..6723b8a4935 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 @@ -166,6 +166,28 @@ describe.each([{ workspaceId: 'ws-1' }, { workspaceId: '', organizationId: 'org- } }) + it('opens a fresh search when replacing a selected dismissed 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()) + act(() => { + textarea.setSelectionRange(0, textarea.value.length) + result().handleSelectAdjust() + }) + input('@Roadmap') + 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) => { 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 53be59ed544..87292657bec 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 @@ -707,6 +707,14 @@ export function usePromptEditor({ const syncMentionState = useCallback( (textarea: HTMLTextAreaElement, text: string, caret: number) => { if (!contextsEnabledRef.current) return + const dismissedStart = dismissedMentionStartRef.current + if ( + dismissedStart !== null && + textarea.selectionStart <= dismissedStart && + textarea.selectionEnd > dismissedStart + ) { + dismissedMentionStartRef.current = null + } const active = getActiveMentionAtRef.current(caret, text) const isOpenable = active && !/[\r\n]/.test(active.query) if (!isOpenable) { From bee13812310b2c8f00dd1b1560f3eb25a397cd96 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 1 Oct 2026 01:03:03 -0700 Subject: [PATCH 3/3] fix(chat): reset replaced queries and bound workspace previews --- .../plus-menu-dropdown/plus-menu-dropdown.tsx | 4 +- .../prompt-editor/use-prompt-editor.test.tsx | 54 ++++++++++++------- .../prompt-editor/use-prompt-editor.ts | 43 +++++++++------ 3 files changed, 64 insertions(+), 37 deletions(-) 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 ea841590017..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 @@ -233,7 +233,9 @@ export const PlusMenuDropdown = React.memo( const filteredItems = useMemo((): MentionCandidate[] | null => { const q = query.toLowerCase().trim() if (!isMention && !q) return null - const workspaceItems: MentionCandidate[] = workspaces + const workspaceItems: MentionCandidate[] = ( + q ? workspaces : workspaces.slice(0, MENTION_PREVIEW_DEFAULT_LIMIT) + ) .filter((workspace) => workspace.name.toLowerCase().includes(q)) .map((item) => ({ type: 'workspace', item })) const resourceItems = q 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 6723b8a4935..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 @@ -166,27 +166,41 @@ describe.each([{ workspaceId: 'ws-1' }, { workspaceId: '', organizationId: 'org- } }) - it('opens a fresh search when replacing a selected dismissed 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()) - act(() => { - textarea.setSelectionRange(0, textarea.value.length) - result().handleSelectAdjust() - }) - input('@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', 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 87292657bec..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 @@ -220,7 +220,7 @@ export function usePromptEditor({ * (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({ @@ -337,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) }, []) @@ -410,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) @@ -438,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) @@ -492,7 +502,7 @@ export function usePromptEditor({ atInsertPosRef.current = newPos mentionRangeRef.current = null setMentionQuery(null) - dismissedMentionStartRef.current = null + dismissedMentionRef.current = null setValueState(newValue) } @@ -692,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) @@ -707,13 +719,11 @@ export function usePromptEditor({ const syncMentionState = useCallback( (textarea: HTMLTextAreaElement, text: string, caret: number) => { if (!contextsEnabledRef.current) return - const dismissedStart = dismissedMentionStartRef.current - if ( - dismissedStart !== null && - textarea.selectionStart <= dismissedStart && - textarea.selectionEnd > dismissedStart - ) { - dismissedMentionStartRef.current = null + 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) const isOpenable = active && !/[\r\n]/.test(active.query) @@ -723,18 +733,18 @@ export function usePromptEditor({ 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 } - dismissedMentionStartRef.current = null + dismissedMentionRef.current = null const wasActive = mentionRangeRef.current !== null mentionRangeRef.current = { start: active.start, end: active.end } @@ -829,7 +839,8 @@ export function usePromptEditor({ const previousValue = valueRef.current const nextValue = e.target.value const hasMentionQuery = - mentionRangeRef.current !== null || dismissedMentionStartRef.current !== null + mentionRangeRef.current !== null || dismissedMentionRef.current !== null + if (dismissedMentionRef.current?.triggerSelected) dismissedMentionRef.current = null let finalValue = nextValue if (