From 36c990a0cf5c2dc2f481246ae4c866a21d65798d Mon Sep 17 00:00:00 2001 From: Souravrajvi0 <144546710+Souravrajvi0@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:06:30 +0530 Subject: [PATCH] fix(web): only open slash variable picker when / is typed (#39755) (#39761) --- .../__tests__/index.spec.tsx | 65 +++++++++++++++++-- .../plugins/component-picker-block/index.tsx | 37 ++++++++++- 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx index 7e60d194444..c8e7ecb2b8f 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx @@ -228,6 +228,7 @@ async function setEditorText( editor: LexicalEditor, text: string, selectEnd: boolean, + selectionOffset?: number, ): Promise { await act(async () => { editor.update(() => { @@ -237,11 +238,28 @@ async function setEditorText( const textNode = $createTextNode(text) paragraph.append(textNode) root.append(paragraph) - if (selectEnd) textNode.selectEnd() + if (typeof selectionOffset === 'number') textNode.select(selectionOffset, selectionOffset) + else if (selectEnd) textNode.selectEnd() }) }) } +function markSlashTyped(editable: HTMLElement) { + editable.dispatchEvent( + new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'insertText', + data: '/', + }), + ) +} + +async function insertSlashTrigger(editor: LexicalEditor, editable: HTMLElement): Promise { + markSlashTyped(editable) + await setEditorText(editor, '/', true) +} + function readEditorText(editor: LexicalEditor): string { return editor.getEditorState().read(() => $getRoot().getTextContent()) } @@ -642,6 +660,44 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { }) }) + it('opens the slash menu when the user types / but not when clicking after an existing slash', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + const urlPrompt = 'Use https://dict.youdao.com/dictvoice?audio=word&type=0' + + render( + , + ) + + const editor = await waitForEditor(captures) + const editable = screen.getByTestId(CONTENT_EDITABLE_TEST_ID) + + await insertSlashTrigger(editor, editable) + await flushNextTick() + + expect(await screen.findByText('output')).toBeInTheDocument() + + await setEditorText(editor, urlPrompt, false, urlPrompt.indexOf('://') + 2) + await flushNextTick() + + fireEvent.pointerDown(editable) + fireEvent.click(editable) + await flushNextTick() + + expect(screen.queryByText('output')).not.toBeInTheDocument() + expect(screen.queryByText('workflow.nodes.agent.outputVars.newOutput')).not.toBeInTheDocument() + }) + it('clears slash trigger state after creating an agent output from the footer action', async () => { const captures: Captures = { editor: null, eventEmitter: null } @@ -662,8 +718,9 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { const editor = await waitForEditor(captures) const dispatchSpy = vi.spyOn(editor, 'dispatchCommand') + const editable = screen.getByTestId(CONTENT_EDITABLE_TEST_ID) - await setEditorText(editor, '/', true) + await insertSlashTrigger(editor, editable) await flushNextTick() const newOutputAction = await screen.findByText('workflow.nodes.agent.outputVars.newOutput') @@ -677,7 +734,6 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { ).not.toBeInTheDocument() }) - const editable = screen.getByTestId(CONTENT_EDITABLE_TEST_ID) fireEvent.focus(editable) await flushNextTick() @@ -1028,7 +1084,8 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { ) const editor = await waitForEditor(captures) - await setEditorText(editor, '/', true) + const editable = screen.getByTestId(CONTENT_EDITABLE_TEST_ID) + await insertSlashTrigger(editor, editable) expect(await screen.findByText('payload')).toBeInTheDocument() vi.useFakeTimers() diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx index 97d4184f8ee..56a7ed63d10 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx @@ -83,6 +83,7 @@ const ComponentPicker = ({ }) const [editor] = useLexicalComposerContext() const triggerMatchRef = useRef(null) + const slashTypedRef = useRef(false) const baseCheckForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, { minLength: 0, maxLength: 75, @@ -91,10 +92,22 @@ const ComponentPicker = ({ const checkForTriggerMatch = useCallback( (text: string, editor: LexicalEditor) => { const match = baseCheckForTriggerMatch(text, editor) + if ( + match && + triggerString === '/' && + match.matchingString.length === 0 && + !slashTypedRef.current + ) { + triggerMatchRef.current = null + return null + } + if (match && triggerString === '/' && match.matchingString.length === 0) + slashTypedRef.current = false + triggerMatchRef.current = match return match }, - [baseCheckForTriggerMatch], + [baseCheckForTriggerMatch, triggerString], ) const [queryString, setQueryString] = useState(null) @@ -140,7 +153,27 @@ const ComponentPicker = ({ if (blurTimerRef.current) clearTimeout(blurTimerRef.current) unregister() } - }, [editor, clearBlurTimer]) + }, [editor, clearBlurTimer, triggerString]) + + useEffect(() => { + const rootElement = editor.getRootElement() + if (!rootElement || triggerString !== '/') return + + const handleBeforeInput = (event: InputEvent) => { + if (event.inputType === 'insertText' && event.data === '/') slashTypedRef.current = true + } + + const handlePointerDown = () => { + slashTypedRef.current = false + } + + rootElement.addEventListener('beforeinput', handleBeforeInput) + rootElement.addEventListener('pointerdown', handlePointerDown) + return () => { + rootElement.removeEventListener('beforeinput', handleBeforeInput) + rootElement.removeEventListener('pointerdown', handlePointerDown) + } + }, [editor, triggerString]) eventEmitter?.useSubscription((v: EventEmitterValue) => { if (