Skip to content

Commit 3c11e59

Browse files
committed
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
1 parent b686111 commit 3c11e59

2 files changed

Lines changed: 274 additions & 0 deletions

File tree

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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+
31+
/**
32+
* Mounts `usePromptEditor` in a real React 19 root under jsdom (no
33+
* `@testing-library/react` in this repo — see `hooks/queries/unsubscribe.test.tsx`
34+
* for the established pattern) and wires a real `<textarea>` into its
35+
* `textareaRef` so selection/caret-driven behavior (`handleSelectAdjust`,
36+
* `syncMentionState`) runs exactly as it does in the rendered `PromptEditor`.
37+
*/
38+
function renderPromptEditor(props: UsePromptEditorProps) {
39+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
40+
const container = document.createElement('div')
41+
document.body.appendChild(container)
42+
const root: Root = createRoot(container)
43+
let latest: ReturnType<typeof usePromptEditor>
44+
45+
function Probe() {
46+
latest = usePromptEditor(props)
47+
return null
48+
}
49+
50+
function Wrapper({ children }: { children: ReactNode }) {
51+
return <>{children}</>
52+
}
53+
54+
act(() => {
55+
root.render(
56+
<Wrapper>
57+
<Probe />
58+
</Wrapper>
59+
)
60+
})
61+
62+
const textarea = document.createElement('textarea')
63+
document.body.appendChild(textarea)
64+
latest!.textareaRef.current = textarea
65+
66+
return {
67+
result: () => latest,
68+
textarea,
69+
unmount: () => {
70+
act(() => root.unmount())
71+
container.remove()
72+
textarea.remove()
73+
},
74+
}
75+
}
76+
77+
/** Fires a native `input` event carrying the new value, as the textarea would on a keystroke. */
78+
function typeInto(textarea: HTMLTextAreaElement, value: string, caret = value.length) {
79+
textarea.value = value
80+
textarea.setSelectionRange(caret, caret)
81+
textarea.dispatchEvent(new Event('input', { bubbles: true }))
82+
}
83+
84+
describe('usePromptEditor mention menu dismissal', () => {
85+
let openMenu: PlusMenuHandle
86+
87+
beforeEach(() => {
88+
openMenu = {
89+
open: vi.fn(),
90+
close: vi.fn(),
91+
moveActive: vi.fn(),
92+
selectActive: vi.fn(() => false),
93+
}
94+
})
95+
96+
afterEach(() => {
97+
vi.clearAllMocks()
98+
})
99+
100+
it('reopens the menu while the user keeps typing an unmatched mention', () => {
101+
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
102+
result().plusMenuRef.current = openMenu
103+
104+
act(() => {
105+
typeInto(textarea, '@f')
106+
// `onChange` in the real component is wired to `handleInputChange`; the
107+
// hook only exposes it through the returned object, so invoke it the
108+
// same way `<textarea onChange={editor.handleInputChange} />` would.
109+
result().handleInputChange({
110+
target: textarea,
111+
currentTarget: textarea,
112+
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
113+
})
114+
115+
expect(result().mentionQuery).toBe('f')
116+
expect(openMenu.open).toHaveBeenCalledTimes(1)
117+
118+
unmount()
119+
})
120+
121+
it('does not reopen after the user clicks away, even if the caret lands back inside the open mention', () => {
122+
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
123+
result().plusMenuRef.current = openMenu
124+
125+
act(() => {
126+
typeInto(textarea, '@f')
127+
result().handleInputChange({
128+
target: textarea,
129+
currentTarget: textarea,
130+
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
131+
})
132+
expect(result().mentionQuery).toBe('f')
133+
134+
// Radix's DismissableLayer calls this on outside pointerdown / Escape —
135+
// never on a programmatic `plusMenuRef.current.close()`.
136+
act(() => {
137+
result().handlePlusMenuClose()
138+
})
139+
expect(result().mentionQuery).toBeNull()
140+
141+
// The same click that dismissed the popover also moves the caret via
142+
// native textarea behavior; the caret lands back at the end of the text,
143+
// i.e. still inside the unterminated "@f" token. Simulate the resulting
144+
// onSelect/onMouseUp without any further keystroke.
145+
act(() => {
146+
textarea.setSelectionRange(2, 2)
147+
result().handleSelectAdjust()
148+
})
149+
150+
expect(result().mentionQuery).toBeNull()
151+
expect(openMenu.open).toHaveBeenCalledTimes(1) // only the original open — no reopen
152+
153+
unmount()
154+
})
155+
156+
it('lets a further keystroke reopen the same mention after a dismiss', () => {
157+
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
158+
result().plusMenuRef.current = openMenu
159+
160+
act(() => {
161+
typeInto(textarea, '@f')
162+
result().handleInputChange({
163+
target: textarea,
164+
currentTarget: textarea,
165+
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
166+
})
167+
act(() => {
168+
result().handlePlusMenuClose()
169+
})
170+
act(() => {
171+
textarea.setSelectionRange(2, 2)
172+
result().handleSelectAdjust()
173+
})
174+
expect(openMenu.open).toHaveBeenCalledTimes(1)
175+
176+
// User keeps editing the same token — this must reopen it.
177+
act(() => {
178+
typeInto(textarea, '@fo', 3)
179+
result().handleInputChange({
180+
target: textarea,
181+
currentTarget: textarea,
182+
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
183+
})
184+
185+
expect(result().mentionQuery).toBe('fo')
186+
expect(openMenu.open).toHaveBeenCalledTimes(2)
187+
188+
unmount()
189+
})
190+
191+
it('does not suppress a different mention typed after a dismiss', () => {
192+
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
193+
result().plusMenuRef.current = openMenu
194+
195+
act(() => {
196+
typeInto(textarea, '@f')
197+
result().handleInputChange({
198+
target: textarea,
199+
currentTarget: textarea,
200+
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
201+
})
202+
act(() => {
203+
result().handlePlusMenuClose()
204+
})
205+
act(() => {
206+
textarea.setSelectionRange(2, 2)
207+
result().handleSelectAdjust()
208+
})
209+
expect(openMenu.open).toHaveBeenCalledTimes(1)
210+
211+
// Clear the field and start a brand new mention elsewhere in the text —
212+
// a stale dismissal must never leak onto an unrelated token.
213+
act(() => {
214+
typeInto(textarea, '@f done. @g', 11)
215+
result().handleInputChange({
216+
target: textarea,
217+
currentTarget: textarea,
218+
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
219+
})
220+
221+
expect(result().mentionQuery).toBe('g')
222+
expect(openMenu.open).toHaveBeenCalledTimes(2)
223+
224+
unmount()
225+
})
226+
})

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,18 @@ 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 the user just dismissed via outside
184+
* click or Escape. A dismiss alone doesn't move the caret, so the very next
185+
* `selectionchange`/click still lands inside the still-open token range and
186+
* would otherwise reopen the menu it was just closed — the "can't click
187+
* away" bug. Suppresses exactly one reopen per token; cleared the moment
188+
* the user types again (`handleInputChange`) so editing the token still
189+
* works normally.
190+
*/
191+
const dismissedMentionStartRef = useRef<number | null>(null)
192+
const dismissedSlashStartRef = useRef<number | null>(null)
193+
182194
const contextManagement = useContextManagement({ message: value, initialContexts })
183195
const contextManagementRef = useRef(contextManagement)
184196
contextManagementRef.current = contextManagement
@@ -327,9 +339,11 @@ export function usePromptEditor({
327339
plusMenuRef.current?.close()
328340
mentionRangeRef.current = null
329341
setMentionQuery(null)
342+
dismissedMentionStartRef.current = null
330343
skillsMenuRef.current?.close()
331344
slashRangeRef.current = null
332345
setSlashQuery(null)
346+
dismissedSlashStartRef.current = null
333347
if (textareaRef.current) {
334348
textareaRef.current.style.height = 'auto'
335349
}
@@ -368,6 +382,7 @@ export function usePromptEditor({
368382
atInsertPosRef.current = newPos
369383
mentionRangeRef.current = null
370384
setMentionQuery(null)
385+
dismissedMentionStartRef.current = null
371386
setValueState(newValue)
372387
}
373388

@@ -423,6 +438,7 @@ export function usePromptEditor({
423438
valueRef.current = newValue
424439
slashRangeRef.current = null
425440
setSlashQuery(null)
441+
dismissedSlashStartRef.current = null
426442
setValueState(newValue)
427443
}
428444

@@ -432,11 +448,18 @@ export function usePromptEditor({
432448
)
433449

434450
const handleSkillsMenuClose = useCallback(() => {
451+
// See `handlePlusMenuClose` — only reachable via a real Radix dismiss.
452+
dismissedSlashStartRef.current = slashRangeRef.current?.start ?? null
435453
slashRangeRef.current = null
436454
setSlashQuery(null)
437455
}, [])
438456

439457
const handlePlusMenuClose = useCallback(() => {
458+
// Only reachable via Radix's own dismiss detection (outside click / Escape) —
459+
// programmatic closes (`plusMenuRef.current?.close()`) bypass `onOpenChange`
460+
// and never call this. Remember the token so the caret's own selection-change
461+
// handler (fired by the same click) doesn't immediately reopen it.
462+
dismissedMentionStartRef.current = mentionRangeRef.current?.start ?? null
440463
atInsertPosRef.current = null
441464
mentionRangeRef.current = null
442465
setMentionQuery(null)
@@ -463,6 +486,17 @@ export function usePromptEditor({
463486
setMentionQuery(null)
464487
plusMenuRef.current?.close()
465488
}
489+
dismissedMentionStartRef.current = null
490+
return
491+
}
492+
493+
// The user just dismissed the menu for this exact token (outside click /
494+
// Escape) and hasn't typed since — a caret move alone must not reopen it.
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,16 @@ export function usePromptEditor({
491525
setSlashQuery(null)
492526
skillsMenuRef.current?.close()
493527
}
528+
dismissedSlashStartRef.current = null
529+
return
530+
}
531+
532+
// See the mirrored check in `syncMentionState`.
533+
if (active.start === dismissedSlashStartRef.current) {
534+
if (slashRangeRef.current !== null) {
535+
slashRangeRef.current = null
536+
setSlashQuery(null)
537+
}
494538
return
495539
}
496540

@@ -584,6 +628,10 @@ export function usePromptEditor({
584628
const caret = e.target.selectionStart ?? finalValue.length
585629
valueRef.current = finalValue
586630
setValueState(finalValue)
631+
// A keystroke is an active edit — always let it reopen a just-dismissed
632+
// menu, even for the same token the user clicked away from.
633+
dismissedMentionStartRef.current = null
634+
dismissedSlashStartRef.current = null
587635
syncMentionState(e.target, finalValue, caret)
588636
syncSlashState(e.target, finalValue, caret)
589637
},

0 commit comments

Comments
 (0)