diff --git a/packages/plugins/@nocobase/plugin-ai/src/client-v2/ai-employees/chatbox/components/Sender.tsx b/packages/plugins/@nocobase/plugin-ai/src/client-v2/ai-employees/chatbox/components/Sender.tsx index 864c6712111..3f4d41fd1ad 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client-v2/ai-employees/chatbox/components/Sender.tsx +++ b/packages/plugins/@nocobase/plugin-ai/src/client-v2/ai-employees/chatbox/components/Sender.tsx @@ -477,7 +477,7 @@ const UploadFiles: React.FC<{ disabled?: boolean }> = observer(({ disabled }) => return ( chatBoxRef?.current ?? document.body} + getDropContainer={() => chatBoxRef?.current} styles={{ placeholder: { opacity: 0.8, diff --git a/packages/plugins/@nocobase/plugin-ai/src/client-v2/ai-employees/chatbox/components/__tests__/SenderInput.test.tsx b/packages/plugins/@nocobase/plugin-ai/src/client-v2/ai-employees/chatbox/components/__tests__/SenderInput.test.tsx index e05f9bf4f68..b760ab41ca0 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client-v2/ai-employees/chatbox/components/__tests__/SenderInput.test.tsx +++ b/packages/plugins/@nocobase/plugin-ai/src/client-v2/ai-employees/chatbox/components/__tests__/SenderInput.test.tsx @@ -21,6 +21,9 @@ const mocks = vi.hoisted(() => { nickname: 'Sales', }, readonly: false, + chatBoxRef: { + current: null as HTMLDivElement | null, + }, }, chatConversationModel: { currentConversation: undefined as string | undefined, @@ -44,6 +47,7 @@ const mocks = vi.hoisted(() => { return { runtime, + attachmentsGetDropContainer: undefined as (() => HTMLElement | null | undefined) | undefined, send: vi.fn(), cancelRequest: vi.fn(), finishEditingMessage: vi.fn(), @@ -58,7 +62,16 @@ vi.mock('@ant-design/x', async () => { MockButton.displayName = 'MockButton'; return { - Attachments: ({ children }: { children?: React.ReactNode }) =>
{children}
, + Attachments: ({ + children, + getDropContainer, + }: { + children?: React.ReactNode; + getDropContainer?: () => HTMLElement | null | undefined; + }) => { + mocks.attachmentsGetDropContainer = getDropContainer; + return
{children}
; + }, Sender: React.forwardRef< { nativeElement?: HTMLTextAreaElement }, { @@ -167,9 +180,24 @@ describe('Sender input state', () => { mocks.runtime.chatSenderModel.setSenderValue.mockClear(); mocks.runtime.chatSenderModel.setSenderRef.mockClear(); mocks.runtime.chatSenderModel.setShowSenderHint.mockClear(); + mocks.runtime.chatBoxModel.chatBoxRef.current = null; + mocks.attachmentsGetDropContainer = undefined; mocks.send.mockClear(); }); + it('limits attachment drops to the mounted chat box', () => { + const dropContainer = document.createElement('div'); + mocks.runtime.chatBoxModel.chatBoxRef.current = dropContainer; + + render( + , + ); + + expect(mocks.attachmentsGetDropContainer?.()).toBe(dropContainer); + mocks.runtime.chatBoxModel.chatBoxRef.current = null; + expect(mocks.attachmentsGetDropContainer?.()).toBeNull(); + }); + it('keeps typing local until blur', () => { const { getByTestId } = render( ({ getAIEmployees: vi.fn(), refreshAITools: vi.fn(), setRoles: vi.fn(), + setChatBoxRef: vi.fn(), aiEmployees: [] as Array<{ username: string; nickname?: string; category?: string }>, currentEmployee: { username: 'legacy', nickname: 'Legacy' }, currentConversation: undefined as string | undefined, @@ -60,6 +61,7 @@ vi.mock('../../../ai-employees/chatbox/stores/runtime', () => ({ roles: {}, senderValue: mocks.senderValue, setRoles: mocks.setRoles, + setChatBoxRef: mocks.setChatBoxRef, setSenderValue: mocks.setSenderValue, }, chatConversationModel: { @@ -132,6 +134,23 @@ const createAIChatBoxCoreViewNode = (props: AIChatBoxBlockProps) => { }; describe('AIChatBoxCoreView', () => { + it('registers its root as the attachment drop container', () => { + mocks.setChatBoxRef.mockReset(); + mocks.currentConversation = undefined; + mocks.draftMessages = []; + mocks.getAIEmployees.mockResolvedValue([]); + + const { container, unmount } = render(createAIChatBoxCoreViewNode({ showMessages: false, showDisclaimer: false })); + + const root = container.firstElementChild; + const registeredRef = mocks.setChatBoxRef.mock.calls[0][0] as React.MutableRefObject; + expect(registeredRef.current).toBe(root); + expect(root).toHaveStyle({ position: 'relative' }); + + unmount(); + expect(mocks.setChatBoxRef).toHaveBeenLastCalledWith(null); + }); + it('keeps messages in a bounded flex region above the sender', () => { mocks.messagesRendered = false; mocks.currentConversation = undefined; diff --git a/packages/plugins/@nocobase/plugin-ai/src/client-v2/block/ai-chat-box/components/AIChatBoxCoreView.tsx b/packages/plugins/@nocobase/plugin-ai/src/client-v2/block/ai-chat-box/components/AIChatBoxCoreView.tsx index 71a1ec3bd49..73118f75d9b 100644 --- a/packages/plugins/@nocobase/plugin-ai/src/client-v2/block/ai-chat-box/components/AIChatBoxCoreView.tsx +++ b/packages/plugins/@nocobase/plugin-ai/src/client-v2/block/ai-chat-box/components/AIChatBoxCoreView.tsx @@ -46,6 +46,7 @@ export const AIChatBoxCoreView: React.FC = observer(() => { const hasDraftUserMessage = draftMessages.some((message) => message.role === 'user'); const { switchAIEmployee } = useChatBoxActions(runtime); + const chatBoxRef = useRef(null); const defaultUserMessageStateRef = useRef<{ draftKey?: string; value?: string; @@ -54,6 +55,13 @@ export const AIChatBoxCoreView: React.FC = observer(() => { useChatBoxEffect(runtime); + useEffect(() => { + chatBoxModel.setChatBoxRef(chatBoxRef); + return () => { + chatBoxModel.setChatBoxRef(null); + }; + }, [chatBoxModel]); + useEffect(() => { if (currentConversation) { return; @@ -140,12 +148,14 @@ export const AIChatBoxCoreView: React.FC = observer(() => { return (