Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
/**
* @vitest-environment jsdom
*/
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/workflows', () => ({ useWorkflows: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/tables', () => ({ useTablesList: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/workspace-files', () => ({ useWorkspaceFiles: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/kb/knowledge', () => ({ useKnowledgeBasesQuery: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/folders', () => ({ useFolders: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/workspace-file-folders', () => ({
useWorkspaceFileFolders: () => ({ data: [] }),
}))
vi.mock('@/hooks/queries/mothership-chats', () => ({ useMothershipChats: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/schedules', () => ({ useWorkspaceSchedules: () => ({ data: [] }) }))
vi.mock('@/hooks/queries/logs', () => ({ useLogsList: () => ({ data: undefined }) }))
vi.mock('@/blocks/integration-matcher', () => ({
getIntegrationMatcher: () => ({ regex: null, byName: new Map() }),
listIntegrations: () => [],
}))

import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
import {
type UsePromptEditorProps,
usePromptEditor,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor'
import type { SkillsMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown'

/**
* Mounts `usePromptEditor` in a real React 19 root under jsdom (no
* `@testing-library/react` in this repo — see `hooks/queries/unsubscribe.test.tsx`
* for the established pattern) and wires a real `<textarea>` into its
* `textareaRef` so selection/caret-driven behavior (`handleSelectAdjust`,
* `syncMentionState`) runs exactly as it does in the rendered `PromptEditor`.
*/
function renderPromptEditor(props: UsePromptEditorProps) {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
document.body.appendChild(container)
const root: Root = createRoot(container)
let latest: ReturnType<typeof usePromptEditor>

function Probe() {
latest = usePromptEditor(props)
return null
}

function Wrapper({ children }: { children: ReactNode }) {
return <>{children}</>
}

act(() => {
root.render(
<Wrapper>
<Probe />
</Wrapper>
)
})

const textarea = document.createElement('textarea')
document.body.appendChild(textarea)
latest!.textareaRef.current = textarea

return {
result: () => latest,
textarea,
unmount: () => {
act(() => root.unmount())
container.remove()
textarea.remove()
},
}
}

/** Fires a native `input` event carrying the new value, as the textarea would on a keystroke. */
function typeInto(textarea: HTMLTextAreaElement, value: string, caret = value.length) {
textarea.value = value
textarea.setSelectionRange(caret, caret)
textarea.dispatchEvent(new Event('input', { bubbles: true }))
}

describe('usePromptEditor mention menu dismissal', () => {
let openMenu: PlusMenuHandle

beforeEach(() => {
openMenu = {
open: vi.fn(),
close: vi.fn(),
moveActive: vi.fn(),
selectActive: vi.fn(() => false),
}
})

afterEach(() => {
vi.clearAllMocks()
})

it('reopens the menu while the user keeps typing an unmatched mention', () => {
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
result().plusMenuRef.current = openMenu

act(() => {
typeInto(textarea, '@f')
result().handleInputChange({
target: textarea,
currentTarget: textarea,
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
})

expect(result().mentionQuery).toBe('f')
expect(openMenu.open).toHaveBeenCalledTimes(1)

unmount()
})

it('does not reopen after the user clicks away, even if the caret lands back inside the open mention', () => {
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
result().plusMenuRef.current = openMenu

act(() => {
typeInto(textarea, '@f')
result().handleInputChange({
target: textarea,
currentTarget: textarea,
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
})
expect(result().mentionQuery).toBe('f')

act(() => {
result().handlePlusMenuClose()
})
expect(result().mentionQuery).toBeNull()

act(() => {
textarea.setSelectionRange(2, 2)
result().handleSelectAdjust()
})

expect(result().mentionQuery).toBeNull()
expect(openMenu.open).toHaveBeenCalledTimes(1)

unmount()
})

it('lets a further keystroke reopen the same mention after a dismiss', () => {
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
result().plusMenuRef.current = openMenu

act(() => {
typeInto(textarea, '@f')
result().handleInputChange({
target: textarea,
currentTarget: textarea,
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
})
act(() => {
result().handlePlusMenuClose()
})
act(() => {
textarea.setSelectionRange(2, 2)
result().handleSelectAdjust()
})
expect(openMenu.open).toHaveBeenCalledTimes(1)

act(() => {
typeInto(textarea, '@fo', 3)
result().handleInputChange({
target: textarea,
currentTarget: textarea,
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
})

expect(result().mentionQuery).toBe('fo')
expect(openMenu.open).toHaveBeenCalledTimes(2)

unmount()
})

it('does not suppress a different mention typed after a dismiss', () => {
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
result().plusMenuRef.current = openMenu

act(() => {
typeInto(textarea, '@f')
result().handleInputChange({
target: textarea,
currentTarget: textarea,
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
})
act(() => {
result().handlePlusMenuClose()
})
act(() => {
textarea.setSelectionRange(2, 2)
result().handleSelectAdjust()
})
expect(openMenu.open).toHaveBeenCalledTimes(1)

act(() => {
typeInto(textarea, '@f done. @g', 11)
result().handleInputChange({
target: textarea,
currentTarget: textarea,
} as unknown as React.ChangeEvent<HTMLTextAreaElement>)
})

expect(result().mentionQuery).toBe('g')
expect(openMenu.open).toHaveBeenCalledTimes(2)

unmount()
})
})

describe('usePromptEditor toolbar slash trigger after a dismiss', () => {
it('still opens the skills menu when the caret sits at the start of the previously dismissed token', () => {
const skillsMenu: SkillsMenuHandle = {
open: vi.fn(),
close: vi.fn(),
moveActive: vi.fn(),
selectActive: vi.fn(() => false),
}
const { result, textarea, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
result().skillsMenuRef.current = skillsMenu

act(() => {
result().insertSlashTrigger()
})
expect(skillsMenu.open).toHaveBeenCalledTimes(1)

act(() => {
result().handleSkillsMenuClose()
})

act(() => {
textarea.setSelectionRange(0, 0)
result().insertSlashTrigger()
})

expect(skillsMenu.open).toHaveBeenCalledTimes(2)

unmount()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,15 @@ export function usePromptEditor({
const slashRangeRef = useRef<{ start: number; end: number } | null>(null)
const [slashQuery, setSlashQuery] = useState<string | null>(null)

/**
* 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.
*/
const dismissedMentionStartRef = useRef<number | null>(null)
const dismissedSlashStartRef = useRef<number | null>(null)

const contextManagement = useContextManagement({ message: value, initialContexts })
const contextManagementRef = useRef(contextManagement)
contextManagementRef.current = contextManagement
Expand Down Expand Up @@ -327,9 +336,11 @@ export function usePromptEditor({
plusMenuRef.current?.close()
mentionRangeRef.current = null
setMentionQuery(null)
dismissedMentionStartRef.current = null
skillsMenuRef.current?.close()
slashRangeRef.current = null
setSlashQuery(null)
dismissedSlashStartRef.current = null
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
Expand Down Expand Up @@ -368,6 +379,7 @@ export function usePromptEditor({
atInsertPosRef.current = newPos
mentionRangeRef.current = null
setMentionQuery(null)
dismissedMentionStartRef.current = null
setValueState(newValue)
}

Expand Down Expand Up @@ -423,6 +435,7 @@ export function usePromptEditor({
valueRef.current = newValue
slashRangeRef.current = null
setSlashQuery(null)
dismissedSlashStartRef.current = null
setValueState(newValue)
}

Expand All @@ -431,12 +444,24 @@ export function usePromptEditor({
[textareaRef, addContextNotified]
)

/**
* Only reachable via Radix's own dismiss detection (outside click /
* Escape) — programmatic closes (`skillsMenuRef.current?.close()`) bypass
* `onOpenChange` and never call this.
*/
const handleSkillsMenuClose = useCallback(() => {
dismissedSlashStartRef.current = slashRangeRef.current?.start ?? null
slashRangeRef.current = null
setSlashQuery(null)
}, [])

/**
* Only reachable via Radix's own dismiss detection (outside click /
* Escape) — programmatic closes (`plusMenuRef.current?.close()`) bypass
* `onOpenChange` and never call this.
*/
const handlePlusMenuClose = useCallback(() => {
dismissedMentionStartRef.current = mentionRangeRef.current?.start ?? null
atInsertPosRef.current = null
mentionRangeRef.current = null
setMentionQuery(null)
Expand All @@ -463,6 +488,15 @@ export function usePromptEditor({
setMentionQuery(null)
plusMenuRef.current?.close()
}
dismissedMentionStartRef.current = null
return
Comment thread
waleedlatif1 marked this conversation as resolved.
}

if (active.start === dismissedMentionStartRef.current) {
if (mentionRangeRef.current !== null) {
mentionRangeRef.current = null
setMentionQuery(null)
}
return
}

Expand Down Expand Up @@ -491,6 +525,15 @@ export function usePromptEditor({
setSlashQuery(null)
skillsMenuRef.current?.close()
}
dismissedSlashStartRef.current = null
return
}

if (active.start === dismissedSlashStartRef.current) {
if (slashRangeRef.current !== null) {
slashRangeRef.current = null
setSlashQuery(null)
}
Comment thread
waleedlatif1 marked this conversation as resolved.
return
}

Expand Down Expand Up @@ -530,6 +573,7 @@ export function usePromptEditor({
setValueState(newValue)
textarea.value = newValue
textarea.setSelectionRange(newCaret, newCaret)
dismissedSlashStartRef.current = null
syncSlashState(textarea, newValue, newCaret)
}, [textareaRef, syncSlashState])

Expand Down Expand Up @@ -584,6 +628,8 @@ 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)
},
Expand Down
Loading