Skip to content

Commit cad44b9

Browse files
authored
fix(user-input): stop @mention/skill menu from reopening after dismiss (#5464)
* fix(user-input): stop @mention/skill menu from reopening after dismiss - Fixes a bug where the @mention (and /skill) autocomplete menu couldn't be dismissed: clicking away or pressing Escape closed it, but the very next click/selection change reopened it because the caret was still inside the unfinished @token - Adds a one-shot "dismissed" marker per token, set only on a real outside-click/Escape dismiss, cleared the instant the user types again - Shared fix in use-prompt-editor.ts covers every consumer: home chat input, scheduled-task modal, task-details modal * chore(user-input): drop redundant inline comments from the mention-dismiss fix Trims the inline // explanations added in the previous commit down to the two declaration-level doc comments that carry real information — matching the repo's no-inline-comments convention. * fix(user-input): clear slash dismissal marker on explicit toolbar trigger Cursor Bugbot review: insertSlashTrigger (the toolbar Slash button) called syncSlashState without clearing dismissedSlashStartRef, so a stale dismissal could suppress the skills menu on an explicit user action when the new token's start offset coincided with the previously dismissed one. An explicit trigger click must always open the menu, so it now clears the marker first like every other insertion path.
1 parent b3175ae commit cad44b9

2 files changed

Lines changed: 292 additions & 0 deletions

File tree

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type ReactNode } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) }))
9+
vi.mock('@/hooks/queries/workflows', () => ({ useWorkflows: () => ({ data: [] }) }))
10+
vi.mock('@/hooks/queries/tables', () => ({ useTablesList: () => ({ data: [] }) }))
11+
vi.mock('@/hooks/queries/workspace-files', () => ({ useWorkspaceFiles: () => ({ data: [] }) }))
12+
vi.mock('@/hooks/queries/kb/knowledge', () => ({ useKnowledgeBasesQuery: () => ({ data: [] }) }))
13+
vi.mock('@/hooks/queries/folders', () => ({ useFolders: () => ({ data: [] }) }))
14+
vi.mock('@/hooks/queries/workspace-file-folders', () => ({
15+
useWorkspaceFileFolders: () => ({ data: [] }),
16+
}))
17+
vi.mock('@/hooks/queries/mothership-chats', () => ({ useMothershipChats: () => ({ data: [] }) }))
18+
vi.mock('@/hooks/queries/schedules', () => ({ useWorkspaceSchedules: () => ({ data: [] }) }))
19+
vi.mock('@/hooks/queries/logs', () => ({ useLogsList: () => ({ data: undefined }) }))
20+
vi.mock('@/blocks/integration-matcher', () => ({
21+
getIntegrationMatcher: () => ({ regex: null, byName: new Map() }),
22+
listIntegrations: () => [],
23+
}))
24+
25+
import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
26+
import {
27+
type UsePromptEditorProps,
28+
usePromptEditor,
29+
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor'
30+
import type { SkillsMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown'
31+
32+
/**
33+
* Mounts `usePromptEditor` in a real React 19 root under jsdom (no
34+
* `@testing-library/react` in this repo — see `hooks/queries/unsubscribe.test.tsx`
35+
* for the established pattern) and wires a real `<textarea>` into its
36+
* `textareaRef` so selection/caret-driven behavior (`handleSelectAdjust`,
37+
* `syncMentionState`) runs exactly as it does in the rendered `PromptEditor`.
38+
*/
39+
function renderPromptEditor(props: UsePromptEditorProps) {
40+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
41+
const container = document.createElement('div')
42+
document.body.appendChild(container)
43+
const root: Root = createRoot(container)
44+
let latest: ReturnType<typeof usePromptEditor>
45+
46+
function Probe() {
47+
latest = usePromptEditor(props)
48+
return null
49+
}
50+
51+
function Wrapper({ children }: { children: ReactNode }) {
52+
return <>{children}</>
53+
}
54+
55+
act(() => {
56+
root.render(
57+
<Wrapper>
58+
<Probe />
59+
</Wrapper>
60+
)
61+
})
62+
63+
const textarea = document.createElement('textarea')
64+
document.body.appendChild(textarea)
65+
latest!.textareaRef.current = textarea
66+
67+
return {
68+
result: () => latest,
69+
textarea,
70+
unmount: () => {
71+
act(() => root.unmount())
72+
container.remove()
73+
textarea.remove()
74+
},
75+
}
76+
}
77+
78+
/** Fires a native `input` event carrying the new value, as the textarea would on a keystroke. */
79+
function typeInto(textarea: HTMLTextAreaElement, value: string, caret = value.length) {
80+
textarea.value = value
81+
textarea.setSelectionRange(caret, caret)
82+
textarea.dispatchEvent(new Event('input', { bubbles: true }))
83+
}
84+
85+
describe('usePromptEditor mention menu dismissal', () => {
86+
let openMenu: PlusMenuHandle
87+
88+
beforeEach(() => {
89+
openMenu = {
90+
open: vi.fn(),
91+
close: vi.fn(),
92+
moveActive: vi.fn(),
93+
selectActive: vi.fn(() => false),
94+
}
95+
})
96+
97+
afterEach(() => {
98+
vi.clearAllMocks()
99+
})
100+
101+
it('reopens the menu while the user keeps typing an unmatched mention', () => {
102+
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
103+
result().plusMenuRef.current = openMenu
104+
105+
act(() => {
106+
typeInto(textarea, '@f')
107+
result().handleInputChange({
108+
target: textarea,
109+
currentTarget: textarea,
110+
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
111+
})
112+
113+
expect(result().mentionQuery).toBe('f')
114+
expect(openMenu.open).toHaveBeenCalledTimes(1)
115+
116+
unmount()
117+
})
118+
119+
it('does not reopen after the user clicks away, even if the caret lands back inside the open mention', () => {
120+
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
121+
result().plusMenuRef.current = openMenu
122+
123+
act(() => {
124+
typeInto(textarea, '@f')
125+
result().handleInputChange({
126+
target: textarea,
127+
currentTarget: textarea,
128+
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
129+
})
130+
expect(result().mentionQuery).toBe('f')
131+
132+
act(() => {
133+
result().handlePlusMenuClose()
134+
})
135+
expect(result().mentionQuery).toBeNull()
136+
137+
act(() => {
138+
textarea.setSelectionRange(2, 2)
139+
result().handleSelectAdjust()
140+
})
141+
142+
expect(result().mentionQuery).toBeNull()
143+
expect(openMenu.open).toHaveBeenCalledTimes(1)
144+
145+
unmount()
146+
})
147+
148+
it('lets a further keystroke reopen the same mention after a dismiss', () => {
149+
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
150+
result().plusMenuRef.current = openMenu
151+
152+
act(() => {
153+
typeInto(textarea, '@f')
154+
result().handleInputChange({
155+
target: textarea,
156+
currentTarget: textarea,
157+
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
158+
})
159+
act(() => {
160+
result().handlePlusMenuClose()
161+
})
162+
act(() => {
163+
textarea.setSelectionRange(2, 2)
164+
result().handleSelectAdjust()
165+
})
166+
expect(openMenu.open).toHaveBeenCalledTimes(1)
167+
168+
act(() => {
169+
typeInto(textarea, '@fo', 3)
170+
result().handleInputChange({
171+
target: textarea,
172+
currentTarget: textarea,
173+
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
174+
})
175+
176+
expect(result().mentionQuery).toBe('fo')
177+
expect(openMenu.open).toHaveBeenCalledTimes(2)
178+
179+
unmount()
180+
})
181+
182+
it('does not suppress a different mention typed after a dismiss', () => {
183+
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
184+
result().plusMenuRef.current = openMenu
185+
186+
act(() => {
187+
typeInto(textarea, '@f')
188+
result().handleInputChange({
189+
target: textarea,
190+
currentTarget: textarea,
191+
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
192+
})
193+
act(() => {
194+
result().handlePlusMenuClose()
195+
})
196+
act(() => {
197+
textarea.setSelectionRange(2, 2)
198+
result().handleSelectAdjust()
199+
})
200+
expect(openMenu.open).toHaveBeenCalledTimes(1)
201+
202+
act(() => {
203+
typeInto(textarea, '@f done. @g', 11)
204+
result().handleInputChange({
205+
target: textarea,
206+
currentTarget: textarea,
207+
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
208+
})
209+
210+
expect(result().mentionQuery).toBe('g')
211+
expect(openMenu.open).toHaveBeenCalledTimes(2)
212+
213+
unmount()
214+
})
215+
})
216+
217+
describe('usePromptEditor toolbar slash trigger after a dismiss', () => {
218+
it('still opens the skills menu when the caret sits at the start of the previously dismissed token', () => {
219+
const skillsMenu: SkillsMenuHandle = {
220+
open: vi.fn(),
221+
close: vi.fn(),
222+
moveActive: vi.fn(),
223+
selectActive: vi.fn(() => false),
224+
}
225+
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
226+
result().skillsMenuRef.current = skillsMenu
227+
228+
act(() => {
229+
result().insertSlashTrigger()
230+
})
231+
expect(skillsMenu.open).toHaveBeenCalledTimes(1)
232+
233+
act(() => {
234+
result().handleSkillsMenuClose()
235+
})
236+
237+
act(() => {
238+
textarea.setSelectionRange(0, 0)
239+
result().insertSlashTrigger()
240+
})
241+
242+
expect(skillsMenu.open).toHaveBeenCalledTimes(2)
243+
244+
unmount()
245+
})
246+
})

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,15 @@ export function usePromptEditor({
179179
const slashRangeRef = useRef<{ start: number; end: number } | null>(null)
180180
const [slashQuery, setSlashQuery] = useState<string | null>(null)
181181

182+
/**
183+
* Start offset of a mention/slash token most recently dismissed by the user
184+
* (outside click or Escape) without a following keystroke — suppresses a
185+
* single reopen of the menu for that exact token when the caret's own
186+
* selection-change handler runs immediately after.
187+
*/
188+
const dismissedMentionStartRef = useRef<number | null>(null)
189+
const dismissedSlashStartRef = useRef<number | null>(null)
190+
182191
const contextManagement = useContextManagement({ message: value, initialContexts })
183192
const contextManagementRef = useRef(contextManagement)
184193
contextManagementRef.current = contextManagement
@@ -327,9 +336,11 @@ export function usePromptEditor({
327336
plusMenuRef.current?.close()
328337
mentionRangeRef.current = null
329338
setMentionQuery(null)
339+
dismissedMentionStartRef.current = null
330340
skillsMenuRef.current?.close()
331341
slashRangeRef.current = null
332342
setSlashQuery(null)
343+
dismissedSlashStartRef.current = null
333344
if (textareaRef.current) {
334345
textareaRef.current.style.height = 'auto'
335346
}
@@ -368,6 +379,7 @@ export function usePromptEditor({
368379
atInsertPosRef.current = newPos
369380
mentionRangeRef.current = null
370381
setMentionQuery(null)
382+
dismissedMentionStartRef.current = null
371383
setValueState(newValue)
372384
}
373385

@@ -423,6 +435,7 @@ export function usePromptEditor({
423435
valueRef.current = newValue
424436
slashRangeRef.current = null
425437
setSlashQuery(null)
438+
dismissedSlashStartRef.current = null
426439
setValueState(newValue)
427440
}
428441

@@ -431,12 +444,24 @@ export function usePromptEditor({
431444
[textareaRef, addContextNotified]
432445
)
433446

447+
/**
448+
* Only reachable via Radix's own dismiss detection (outside click /
449+
* Escape) — programmatic closes (`skillsMenuRef.current?.close()`) bypass
450+
* `onOpenChange` and never call this.
451+
*/
434452
const handleSkillsMenuClose = useCallback(() => {
453+
dismissedSlashStartRef.current = slashRangeRef.current?.start ?? null
435454
slashRangeRef.current = null
436455
setSlashQuery(null)
437456
}, [])
438457

458+
/**
459+
* Only reachable via Radix's own dismiss detection (outside click /
460+
* Escape) — programmatic closes (`plusMenuRef.current?.close()`) bypass
461+
* `onOpenChange` and never call this.
462+
*/
439463
const handlePlusMenuClose = useCallback(() => {
464+
dismissedMentionStartRef.current = mentionRangeRef.current?.start ?? null
440465
atInsertPosRef.current = null
441466
mentionRangeRef.current = null
442467
setMentionQuery(null)
@@ -463,6 +488,15 @@ export function usePromptEditor({
463488
setMentionQuery(null)
464489
plusMenuRef.current?.close()
465490
}
491+
dismissedMentionStartRef.current = null
492+
return
493+
}
494+
495+
if (active.start === dismissedMentionStartRef.current) {
496+
if (mentionRangeRef.current !== null) {
497+
mentionRangeRef.current = null
498+
setMentionQuery(null)
499+
}
466500
return
467501
}
468502

@@ -491,6 +525,15 @@ export function usePromptEditor({
491525
setSlashQuery(null)
492526
skillsMenuRef.current?.close()
493527
}
528+
dismissedSlashStartRef.current = null
529+
return
530+
}
531+
532+
if (active.start === dismissedSlashStartRef.current) {
533+
if (slashRangeRef.current !== null) {
534+
slashRangeRef.current = null
535+
setSlashQuery(null)
536+
}
494537
return
495538
}
496539

@@ -530,6 +573,7 @@ export function usePromptEditor({
530573
setValueState(newValue)
531574
textarea.value = newValue
532575
textarea.setSelectionRange(newCaret, newCaret)
576+
dismissedSlashStartRef.current = null
533577
syncSlashState(textarea, newValue, newCaret)
534578
}, [textareaRef, syncSlashState])
535579

@@ -584,6 +628,8 @@ export function usePromptEditor({
584628
const caret = e.target.selectionStart ?? finalValue.length
585629
valueRef.current = finalValue
586630
setValueState(finalValue)
631+
dismissedMentionStartRef.current = null
632+
dismissedSlashStartRef.current = null
587633
syncMentionState(e.target, finalValue, caret)
588634
syncSlashState(e.target, finalValue, caret)
589635
},

0 commit comments

Comments
 (0)