diff --git a/packages/app/src/components/EditorPane.tsx b/packages/app/src/components/EditorPane.tsx index 22a5a087..49e570b8 100644 --- a/packages/app/src/components/EditorPane.tsx +++ b/packages/app/src/components/EditorPane.tsx @@ -18,8 +18,7 @@ import { EditorArea } from './EditorArea'; import { EditorHeader } from './EditorHeader'; import { OpenInAgentMenuRequestProvider } from './handoff/OpenInAgentMenuRequestContext'; import { - buildHandoffInput, - buildSelectionHandoffInput, + buildSelectionOrDocHandoffInput, type HandoffDispatchInput, } from './handoff/useHandoffDispatch'; @@ -85,13 +84,12 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { void; } -export function OpenInAgentMenu({ input, open, onOpenChange }: OpenInAgentMenuProps): ReactNode { - const { t } = useLingui(); - const { states, refresh } = useInstalledAgents(); - const { dispatch } = useHandoffDispatch(); - const { merged } = useConfigContext(); - const autoOpen = merged?.appearance?.preview?.autoOpen ?? true; - const [internalOpen, setInternalOpen] = useState(false); - const sawPointerDownRef = useRef(false); - const isEmbedded = useIsEmbedded(); - if (isEmbedded) return null; - - const isElectronHost = typeof window !== 'undefined' && window.okDesktop != null; - const menuOpen = open ?? internalOpen; - - const handleOpenChange = (next: boolean): void => { - if (open === undefined) setInternalOpen(next); - onOpenChange?.(next); - if (next) void refresh(); - }; +interface OpenInAgentMenuContentProps { + readonly input: HandoffDispatchInput | null; + readonly states: Record; + readonly dispatch: ( + target: HandoffTarget, + input: HandoffDispatchInput, + ) => Promise; + readonly isElectronHost: boolean; + readonly autoOpen: boolean; + readonly align?: ComponentProps['align']; + readonly className?: string; +} - const triggerDisabled = input === null; +export function OpenInAgentMenuContent({ + input, + states, + dispatch, + isElectronHost, + autoOpen, + align = 'end', + className = 'min-w-[220px]', +}: OpenInAgentMenuContentProps): ReactNode { + const { t } = useLingui(); const isSelectionScope = Boolean(input?.selection); const prompt = input !== null && input.docContext !== null @@ -75,6 +83,71 @@ export function OpenInAgentMenu({ input, open, onOpenChange }: OpenInAgentMenuPr void dispatchClaudeWebFallback(prompt); }; + return ( + + {installedTargets.map((target) => { + const installState = states[target.id]; + return ( + handleSelect(target)} + /> + ); + })} + {isSelectionScope && installedTargets.length === 0 ? ( + + {probePending ? ( + Checking for installed agents + ) : ( + No installed agents found + )} + + ) : null} + {!claudeInstalled && !isSelectionScope ? ( + + + ) : null} + + ); +} + +export function OpenInAgentMenu({ input, open, onOpenChange }: OpenInAgentMenuProps): ReactNode { + const { states, refresh } = useInstalledAgents(); + const { dispatch } = useHandoffDispatch(); + const { merged } = useConfigContext(); + const autoOpen = merged?.appearance?.preview?.autoOpen ?? true; + const [internalOpen, setInternalOpen] = useState(false); + const sawPointerDownRef = useRef(false); + const isEmbedded = useIsEmbedded(); + if (isEmbedded) return null; + + const isElectronHost = typeof window !== 'undefined' && window.okDesktop != null; + const menuOpen = open ?? internalOpen; + + const handleOpenChange = (next: boolean): void => { + if (open === undefined) setInternalOpen(next); + onOpenChange?.(next); + if (next) void refresh(); + }; + + const triggerDisabled = input === null; + return ( @@ -107,46 +180,13 @@ export function OpenInAgentMenu({ input, open, onOpenChange }: OpenInAgentMenuPr Open with AI - - {installedTargets.map((target) => { - const installState = states[target.id]; - return ( - handleSelect(target)} - /> - ); - })} - {isSelectionScope && installedTargets.length === 0 ? ( - - {probePending ? ( - Checking for installed agents - ) : ( - No installed agents found - )} - - ) : null} - {!claudeInstalled && !isSelectionScope ? ( - - - ) : null} - + ); } diff --git a/packages/app/src/components/handoff/useHandoffDispatch.test.ts b/packages/app/src/components/handoff/useHandoffDispatch.test.ts index 941182fa..953557ad 100644 --- a/packages/app/src/components/handoff/useHandoffDispatch.test.ts +++ b/packages/app/src/components/handoff/useHandoffDispatch.test.ts @@ -965,6 +965,54 @@ describe('buildSelectionHandoffInput — selection-scoped helper', () => { }); }); +describe('buildSelectionOrDocHandoffInput — selection/file fallback helper', () => { + test('prefers selection scope when the serialized selection is non-empty', async () => { + const { buildSelectionOrDocHandoffInput } = await import('./useHandoffDispatch'); + const input = buildSelectionOrDocHandoffInput({ + docName: 'specs/foo/SPEC', + workspace: { contentDir: '/repo', pathSeparator: '/' }, + instruction: 'rewrite', + selectionMarkdown: 'selected passage', + }); + + expect(input?.docContext).toBeNull(); + expect(input?.selection).toEqual({ + relativePath: 'specs/foo/SPEC.md', + instruction: 'rewrite', + selectionMarkdown: 'selected passage', + }); + expect(input?.docPath).toBe('/repo/specs/foo/SPEC.md'); + }); + + test('falls back to file scope when the serialized selection is empty', async () => { + const { buildSelectionOrDocHandoffInput } = await import('./useHandoffDispatch'); + const input = buildSelectionOrDocHandoffInput({ + docName: 'specs/foo/SPEC', + workspace: { contentDir: '/repo', pathSeparator: '/' }, + instruction: 'rewrite', + selectionMarkdown: '', + }); + + expect(input).toEqual({ + docContext: { relativePath: 'specs/foo/SPEC.md' }, + projectDir: '/repo', + docPath: '/repo/specs/foo/SPEC.md', + }); + }); + + test('returns null when neither selection nor file scope can be built', async () => { + const { buildSelectionOrDocHandoffInput } = await import('./useHandoffDispatch'); + expect( + buildSelectionOrDocHandoffInput({ + docName: null, + workspace: { contentDir: '/repo', pathSeparator: '/' }, + instruction: 'rewrite', + selectionMarkdown: '', + }), + ).toBeNull(); + }); +}); + describe('runHandoffDispatch — selection scope', () => { function selectionInput(): HandoffDispatchInput { return { diff --git a/packages/app/src/components/handoff/useHandoffDispatch.ts b/packages/app/src/components/handoff/useHandoffDispatch.ts index 81f61824..779e2ae6 100644 --- a/packages/app/src/components/handoff/useHandoffDispatch.ts +++ b/packages/app/src/components/handoff/useHandoffDispatch.ts @@ -110,6 +110,15 @@ export function buildSelectionHandoffInput(args: { }; } +export function buildSelectionOrDocHandoffInput(args: { + readonly docName: string | null; + readonly workspace: Workspace | null; + readonly instruction: string; + readonly selectionMarkdown: string; +}): HandoffDispatchInput | null { + return buildSelectionHandoffInput(args) ?? buildHandoffInput(args); +} + export function openInstallUrl(target: TargetData): Promise { return defaultOpenExternal(target.installUrl).then(() => undefined); } diff --git a/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.dom.test.tsx b/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.dom.test.tsx index 178b1f12..6820ae3d 100644 --- a/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.dom.test.tsx +++ b/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.dom.test.tsx @@ -4,16 +4,46 @@ import userEvent from '@testing-library/user-event'; import { Schema } from '@tiptap/pm/model'; import type { Editor } from '@tiptap/react'; import type { ReactNode } from 'react'; +import { DropdownMenuContent } from '@/components/ui/dropdown-menu'; +import { ConfigContext, type ConfigContextValue } from '@/lib/config-context'; +import type { HandoffDispatchInput } from '../../components/handoff/useHandoffDispatch'; import { setEditorDocName } from '../extensions/doc-context.ts'; -const openRequests: unknown[] = []; const toastError = mock(() => {}); +const refreshInstalledAgents = mock(async () => {}); +let latestMenuInput: HandoffDispatchInput | null | undefined; +let lastNonNullMenuInput: HandoffDispatchInput | null | undefined; mock.module('sonner', () => ({ toast: { error: toastError } })); -const { OpenInAgentMenuRequestProvider } = await import( - '../../components/handoff/OpenInAgentMenuRequestContext' -); +mock.module('@/components/handoff/OpenInAgentMenu', () => ({ + OpenInAgentMenuContent: ({ input }: { input: HandoffDispatchInput | null }) => { + latestMenuInput = input; + if (input !== null) lastNonNullMenuInput = input; + if (input === null) return null; + return ( + + {input.selection?.selectionMarkdown ?? 'file scope'} + + ); + }, +})); + +mock.module('@/components/handoff/useInstalledAgents', () => ({ + useInstalledAgents: () => ({ + states: { + 'claude-code': { installed: true, lastChecked: 1 }, + codex: { installed: true, lastChecked: 1 }, + cursor: { installed: true, lastChecked: 1 }, + }, + refresh: refreshInstalledAgents, + }), +})); + +mock.module('@/lib/use-workspace', () => ({ + useWorkspace: () => ({ contentDir: '/tmp/project', pathSeparator: '/' }), +})); + const { EditWithAiBubbleButton } = await import('./EditWithAiBubbleButton'); const schema = new Schema({ @@ -26,15 +56,17 @@ const schema = new Schema({ function makeEditor(docName: string, text: string) { let doc = schema.node('doc', null, [schema.node('paragraph', null, [schema.text(text)])]); + const selectionContent = mock(() => doc.slice(0, doc.content.size)); const editor = { state: { schema, - selection: { content: () => doc.slice(0, doc.content.size) }, + selection: { content: selectionContent }, }, } as unknown as Editor; setEditorDocName(editor, docName); return { editor, + selectionContent, setSelectionText(next: string) { doc = schema.node('doc', null, [schema.node('paragraph', null, [schema.text(next)])]); }, @@ -72,6 +104,19 @@ function setUserAgent(userAgent: string): void { const PLAIN_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120 Safari/537.36'; const EMBEDDED_UA = `${PLAIN_UA} Cursor/1.2.3`; +const configContextValue = { + userBinding: null, + userSynced: false, + projectBinding: null, + projectLocalBinding: null, + okignoreBinding: null, + okignoreSynced: false, + userConfig: null, + projectConfig: null, + projectLocalConfig: null, + projectLocalSynced: false, + merged: { appearance: { preview: { autoOpen: true } } }, +} as ConfigContextValue; function renderButton({ editor, @@ -83,24 +128,19 @@ function renderButton({ before?: ReactNode; }) { return render( - + {before} - , + , ); } afterEach(() => { cleanup(); - openRequests.length = 0; + latestMenuInput = undefined; + lastNonNullMenuInput = undefined; toastError.mockClear(); + refreshInstalledAgents.mockClear(); setUserAgent(PLAIN_UA); }); @@ -152,10 +192,11 @@ describe('EditWithAiBubbleButton', () => { ); }); - expect(openRequests).toEqual([]); + expect(screen.queryByTestId('edit-with-ai-popover')).toBeNull(); + expect(latestMenuInput).toBeUndefined(); }); - test('clicking the trigger requests the header Open with AI menu', async () => { + test('clicking the trigger opens the local Open with AI menu', async () => { setPlatform('MacIntel'); const user = userEvent.setup(); const { editor } = makeEditor('specs/foo/SPEC', 'A passage.'); @@ -163,17 +204,99 @@ describe('EditWithAiBubbleButton', () => { await user.click(screen.getByTestId('edit-with-ai-bubble-button')); + expect(screen.getByTestId('edit-with-ai-popover')).toBeTruthy(); + expect(latestMenuInput?.selection).toEqual({ + relativePath: 'specs/foo/SPEC.md', + instruction: '', + selectionMarkdown: 'A passage.', + }); + expect(refreshInstalledAgents).toHaveBeenCalled(); + }); + + test('pressing Enter on the trigger opens the local Open with AI menu', async () => { + setPlatform('MacIntel'); + const user = userEvent.setup(); + const { editor } = makeEditor('specs/foo/SPEC', 'A passage.'); + renderButton({ editor }); + + screen.getByTestId('edit-with-ai-bubble-button').focus(); + await user.keyboard('{Enter}'); + + expect(screen.getByTestId('edit-with-ai-popover')).toBeTruthy(); + expect(latestMenuInput?.selection?.selectionMarkdown).toBe('A passage.'); + }); + + test('pressing Space on the trigger opens the local Open with AI menu', async () => { + setPlatform('MacIntel'); + const { editor } = makeEditor('specs/foo/SPEC', 'A passage.'); + renderButton({ editor }); + + await act(async () => { + screen.getByTestId('edit-with-ai-bubble-button').dispatchEvent( + new KeyboardEvent('keydown', { + key: ' ', + code: 'Space', + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(screen.getByTestId('edit-with-ai-popover')).toBeTruthy(); + expect(latestMenuInput?.selection?.selectionMarkdown).toBe('A passage.'); + }); + + test('synthetic click activation opens the local Open with AI menu', async () => { + setPlatform('MacIntel'); + const { editor } = makeEditor('specs/foo/SPEC', 'A passage.'); + renderButton({ editor }); + + await act(async () => { + screen.getByTestId('edit-with-ai-bubble-button').click(); + }); + + expect(screen.getByTestId('edit-with-ai-popover')).toBeTruthy(); + expect(latestMenuInput?.selection).toMatchObject({ + selectionMarkdown: 'A passage.', + }); + }); + + test('clicking an already-open trigger does not replace the captured selection', async () => { + setPlatform('MacIntel'); + const user = userEvent.setup(); + const { editor, setSelectionText } = makeEditor('specs/foo/SPEC', 'Original passage.'); + renderButton({ editor }); + + await user.click(screen.getByTestId('edit-with-ai-bubble-button')); + expect(latestMenuInput?.selection?.selectionMarkdown).toBe('Original passage.'); + + setSelectionText('Changed after open.'); + await user.click(screen.getByTestId('edit-with-ai-bubble-button')); + + expect(lastNonNullMenuInput?.selection?.selectionMarkdown).toBe('Original passage.'); + }); + + test('closing and reopening the trigger captures the current selection', async () => { + setPlatform('MacIntel'); + const user = userEvent.setup(); + const { editor, setSelectionText } = makeEditor('specs/foo/SPEC', 'Original passage.'); + renderButton({ editor }); + + await user.click(screen.getByTestId('edit-with-ai-bubble-button')); + expect(latestMenuInput?.selection?.selectionMarkdown).toBe('Original passage.'); + + await user.keyboard('{Escape}'); expect(screen.queryByTestId('edit-with-ai-popover')).toBeNull(); - expect(openRequests).toEqual([ - { - docName: 'specs/foo/SPEC', - instruction: '', - selectionMarkdown: 'A passage.', - }, - ]); + expect(latestMenuInput).toBeNull(); + + setSelectionText('Changed after close.'); + await user.click(screen.getByTestId('edit-with-ai-bubble-button')); + + expect(screen.getByTestId('edit-with-ai-popover')).toBeTruthy(); + expect(latestMenuInput?.selection?.selectionMarkdown).toBe('Changed after close.'); }); - test('selection serialization failure shows an error toast without dispatching', async () => { + test('selection serialization failure shows an error toast without opening the menu', async () => { setPlatform('MacIntel'); const user = userEvent.setup(); const editor = makeThrowingEditor('specs/foo/SPEC'); @@ -188,13 +311,37 @@ describe('EditWithAiBubbleButton', () => { console.error = originalConsoleError; } - expect(openRequests).toEqual([]); + expect(screen.queryByTestId('edit-with-ai-popover')).toBeNull(); + expect(latestMenuInput).toBeNull(); expect(consoleError).toHaveBeenCalledTimes(1); expect(toastError).toHaveBeenCalledTimes(1); expect(toastError.mock.calls[0]?.[0]).toBe("Couldn't read the selection — please try again."); }); - test('Cmd+Shift+I requests the header Open with AI menu', async () => { + test('keyboard selection serialization failure shows an error toast without opening the menu', async () => { + setPlatform('MacIntel'); + const user = userEvent.setup(); + const editor = makeThrowingEditor('specs/foo/SPEC'); + const originalConsoleError = console.error; + const consoleError = mock(() => {}); + renderButton({ editor }); + + console.error = consoleError as typeof console.error; + try { + screen.getByTestId('edit-with-ai-bubble-button').focus(); + await user.keyboard('{Enter}'); + } finally { + console.error = originalConsoleError; + } + + expect(screen.queryByTestId('edit-with-ai-popover')).toBeNull(); + expect(latestMenuInput).toBeNull(); + expect(consoleError).toHaveBeenCalledTimes(1); + expect(toastError).toHaveBeenCalledTimes(1); + expect(toastError.mock.calls[0]?.[0]).toBe("Couldn't read the selection — please try again."); + }); + + test('Cmd+Shift+I opens the local Open with AI menu', async () => { setPlatform('MacIntel'); const { editor } = makeEditor('specs/foo/SPEC', 'A passage.'); renderButton({ editor }); @@ -212,14 +359,12 @@ describe('EditWithAiBubbleButton', () => { ); }); - expect(screen.queryByTestId('edit-with-ai-popover')).toBeNull(); - expect(openRequests).toEqual([ - { - docName: 'specs/foo/SPEC', - instruction: '', - selectionMarkdown: 'A passage.', - }, - ]); + expect(screen.getByTestId('edit-with-ai-popover')).toBeTruthy(); + expect(latestMenuInput?.selection).toEqual({ + relativePath: 'specs/foo/SPEC.md', + instruction: '', + selectionMarkdown: 'A passage.', + }); }); test('Cmd+Shift+I ignores inactive mounted editors', async () => { @@ -241,7 +386,7 @@ describe('EditWithAiBubbleButton', () => { }); expect(screen.queryByTestId('edit-with-ai-popover')).toBeNull(); - expect(openRequests).toEqual([]); + expect(latestMenuInput).toBeNull(); }); test('Cmd+Shift+I ignores native text inputs', async () => { @@ -262,7 +407,8 @@ describe('EditWithAiBubbleButton', () => { ); }); - expect(openRequests).toEqual([]); + expect(screen.queryByTestId('edit-with-ai-popover')).toBeNull(); + expect(latestMenuInput).toBeNull(); }); test('request carries the editor doc name and serialized selection', async () => { @@ -273,13 +419,11 @@ describe('EditWithAiBubbleButton', () => { await user.click(screen.getByTestId('edit-with-ai-bubble-button')); - expect(openRequests).toEqual([ - { - docName: 'specs/foo/SPEC', - instruction: '', - selectionMarkdown: 'The selected passage.', - }, - ]); + expect(latestMenuInput?.selection).toEqual({ + relativePath: 'specs/foo/SPEC.md', + instruction: '', + selectionMarkdown: 'The selected passage.', + }); }); test('a selection change after the request does not alter the dispatched passage', async () => { @@ -291,7 +435,8 @@ describe('EditWithAiBubbleButton', () => { await user.click(screen.getByTestId('edit-with-ai-bubble-button')); setSelectionText('A different passage entirely.'); - expect(openRequests).toHaveLength(1); - expect(openRequests[0]).toMatchObject({ selectionMarkdown: 'Original passage.' }); + expect(latestMenuInput?.selection).toMatchObject({ + selectionMarkdown: 'Original passage.', + }); }); }); diff --git a/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.tsx b/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.tsx index 39104c99..84db3d5e 100644 --- a/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.tsx +++ b/packages/app/src/editor/bubble-menu/EditWithAiBubbleButton.tsx @@ -2,13 +2,30 @@ import { Trans, useLingui } from '@lingui/react/macro'; import { isMacOS } from '@tiptap/core'; import type { Editor } from '@tiptap/react'; import { Sparkles } from 'lucide-react'; -import { type ReactNode, useEffect } from 'react'; +import { + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, + type PointerEvent as ReactPointerEvent, + useEffect, + useEffectEvent, + useRef, + useState, +} from 'react'; import { toast } from 'sonner'; -import { useOpenInAgentMenuRequest } from '@/components/handoff/OpenInAgentMenuRequestContext'; +import { OpenInAgentMenuContent } from '@/components/handoff/OpenInAgentMenu'; +import { + buildSelectionOrDocHandoffInput, + type HandoffDispatchInput, + useHandoffDispatch, +} from '@/components/handoff/useHandoffDispatch'; +import { useInstalledAgents } from '@/components/handoff/useInstalledAgents'; import { Button } from '@/components/ui/button'; +import { DropdownMenu, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Separator } from '@/components/ui/separator'; import { useIsEmbedded } from '@/hooks/use-is-embedded'; +import { useConfigContext } from '@/lib/config-context'; import { matchesKeyboardShortcut } from '@/lib/keyboard-shortcuts'; +import { useWorkspace } from '@/lib/use-workspace'; import { serializeWysiwygSelection } from '../edit-with-ai-selection.ts'; import { getEditorDocName } from '../extensions/doc-context.ts'; @@ -25,30 +42,119 @@ export function EditWithAiBubbleButton({ editor: Editor; shortcutEnabled?: boolean; }): ReactNode { - const { t } = useLingui(); - const { openSelection } = useOpenInAgentMenuRequest(); const isMac = isMacOS(); const isEmbedded = useIsEmbedded(); + if (!isMac || isEmbedded) return null; - const openSelectionMenu = (): void => { + return ; +} + +function EditWithAiBubbleMenu({ + editor, + shortcutEnabled, +}: { + editor: Editor; + shortcutEnabled: boolean; +}): ReactNode { + const { t } = useLingui(); + const { states, refresh } = useInstalledAgents(); + const { dispatch } = useHandoffDispatch(); + const { merged } = useConfigContext(); + const workspace = useWorkspace(); + const [menuOpen, setMenuOpen] = useState(false); + const [menuInput, setMenuInput] = useState(null); + const menuInputRef = useRef(null); + const suppressNextClickRef = useRef(false); + const autoOpen = merged?.appearance?.preview?.autoOpen ?? true; + const isElectronHost = typeof window !== 'undefined' && window.okDesktop != null; + const selectionErrorMessage = t`Couldn't read the selection — please try again.`; + + const captureSelectionInput = (): HandoffDispatchInput | null => { let selectionMarkdown: string; try { selectionMarkdown = serializeWysiwygSelection(editor); } catch (err) { console.error('Edit with AI: could not read the selection', err); - toast.error(t`Couldn't read the selection — please try again.`); - return; + toast.error(selectionErrorMessage); + return null; } - openSelection({ - docName: getEditorDocName(editor), + const docName = getEditorDocName(editor); + const input = buildSelectionOrDocHandoffInput({ + docName, + workspace, instruction: '', selectionMarkdown, }); + if (input === null) { + toast.error(selectionErrorMessage); + return null; + } + return input; + }; + + const primeSelectionMenu = (): boolean => { + const input = captureSelectionInput(); + if (input === null) return false; + menuInputRef.current = input; + setMenuInput(input); + return true; }; + const openSelectionMenu = (): void => { + if (!primeSelectionMenu()) return; + setMenuOpen(true); + void refresh(); + }; + + const handleOpenChange = (open: boolean): void => { + setMenuOpen(open); + if (!open) { + menuInputRef.current = null; + setMenuInput(null); + return; + } + void refresh(); + }; + + const handleTriggerPointerDownCapture = (event: ReactPointerEvent): void => { + if (menuInputRef.current !== null) { + suppressNextClickRef.current = true; + return; + } + if (event.button !== 0) return; + suppressNextClickRef.current = true; + if (primeSelectionMenu()) return; + event.preventDefault(); + event.stopPropagation(); + }; + + const handleTriggerKeyDownCapture = (event: ReactKeyboardEvent): void => { + if (menuInputRef.current !== null) { + suppressNextClickRef.current = true; + return; + } + if (event.key !== 'Enter' && event.key !== ' ') return; + suppressNextClickRef.current = true; + if (primeSelectionMenu()) return; + event.preventDefault(); + event.stopPropagation(); + }; + + const handleTriggerClick = (): void => { + if (suppressNextClickRef.current) { + suppressNextClickRef.current = false; + return; + } + if (menuInputRef.current !== null) return; + openSelectionMenu(); + }; + + const openSelectionMenuEvent = useEffectEvent(() => { + openSelectionMenu(); + }); + useEffect(() => { - if (!isMac || isEmbedded) return; const handleKeyDown = (event: KeyboardEvent): void => { if (!shortcutEnabled) return; if (!matchesKeyboardShortcut(event, 'edit-with-ai')) return; @@ -57,37 +163,42 @@ export function EditWithAiBubbleButton({ event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); - openSelectionMenu(); + openSelectionMenuEvent(); }; window.addEventListener('keydown', handleKeyDown, { capture: true }); return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); - }, [ - isMac, - isEmbedded, - shortcutEnabled, - // biome-ignore lint/correctness/useExhaustiveDependencies: openSelectionMenu is render-bound; re-subscribing keeps the handler fresh for the current editor selection. - openSelectionMenu, - ]); - - if (!isMac || isEmbedded) return null; + }, [shortcutEnabled]); return ( <> - + + + + + + ); }