diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx index a59f9923b53..ba6ff3de48b 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx @@ -193,7 +193,7 @@ async function render( requestMode: 'agent' | 'assistant' = 'assistant', controls: Pick< ComponentProps, - 'isSending' | 'showModeSelector' | 'onModeChange' | 'restoredContexts' + 'isSending' | 'showModeSelector' | 'onModeChange' | 'restoredContexts' | 'onSendQueuedHead' > = { isSending: false } ) { function Harness() { @@ -215,6 +215,7 @@ async function render( isInitialView={isInitialView} isSending={controls.isSending} onStop={vi.fn()} + onSendQueuedHead={controls.onSendQueuedHead} onSubmit={(text, contexts) => { mocks.submit(text, files.attachedFiles) mocks.contexts(contexts) @@ -919,3 +920,42 @@ it('offers the advanced models and each model’s supported efforts', async () = ) ).toEqual(['Low', 'Medium', 'High', 'Extra High', 'Max']) }) + +it.each([ + ['agent', false], + ['assistant', false], + ['agent', true], + ['assistant', true], +] as const)( + 'queues once then sends immediately on rapid double Enter (%s, attachment: %s)', + async (mode, withAttachment) => { + const sendHead = vi.fn() + await render(false, withAttachment ? '' : 'Use the latest report', mode, { + isSending: true, + onSendQueuedHead: sendHead, + }) + if (withAttachment) await paste([new File(['image'], 'follow-up.png', { type: 'image/png' })]) + const input = container.querySelector('[aria-label="Ask Sim"]')! + await act(async () => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + expect(mocks.submit).toHaveBeenCalledTimes(1) + expect(sendHead).not.toHaveBeenCalled() + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + expect(mocks.submit).toHaveBeenCalledTimes(1) + expect(sendHead).toHaveBeenCalledExactlyOnceWith() + expect(input.value).toBe('') + } +) + +it('does not send a queued head on empty Enter when idle', async () => { + const sendHead = vi.fn() + await render(false, '', 'assistant', { isSending: false, onSendQueuedHead: sendHead }) + await act(async () => { + container + .querySelector('[aria-label="Ask Sim"]')! + .dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + expect(sendHead).not.toHaveBeenCalled() + expect(mocks.submit).not.toHaveBeenCalled() +}) diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx index 0193d2ae948..f1a20c6b433 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx @@ -46,6 +46,7 @@ interface ComposerProps { onChange: (value: string, contexts?: ChatContext[]) => void restoredContexts?: ChatContext[] onSubmit: (text: string, contexts?: ChatContext[]) => void + onSendQueuedHead?: () => void onStop: () => void } @@ -65,9 +66,12 @@ export function Composer({ isSending, onChange, onSubmit, + onSendQueuedHead, restoredContexts, onStop, }: ComposerProps) { + const attachedFilesRef = useRef(files.attachedFiles) + attachedFilesRef.current = files.attachedFiles const imagesOnly = requestMode === 'assistant' const { organization } = useOrganizationContext() const { data: allWorkspaces = [] } = useWorkspacesQuery(!imagesOnly) @@ -147,7 +151,15 @@ export function Composer({ const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim' const submit = () => { - if (!canSubmit) return + if (attachedFilesRef.current.some((file) => file.uploading)) return + const hasPayload = + editor.getValue().trim().length > 0 || attachedFilesRef.current.some((file) => file.key) + if (!hasPayload) { + if (isSending) onSendQueuedHead?.() + return + } + /** Consume attachments synchronously so a second Enter cannot submit them twice. */ + attachedFilesRef.current = [] voice.resetTranscript() const contexts = imagesOnly ? [] : editor.getActiveContexts() onSubmit(editor.getPlainValue(), contexts.length ? contexts : undefined) diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx index 459a1e2c432..1307fe05e22 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx @@ -1013,3 +1013,17 @@ it('keeps an attachment-only draft across remounts and clears it when its last f ).toBeUndefined() expect(composerProps().files.attachedFiles).toEqual([]) }) + +it('wires empty Enter to the live queue sender for an active organization chat', async () => { + const sendNow = vi.fn() + mocks.chat.mockReturnValue({ + ...mocks.chat(), + messages: [{ id: 'user-1', role: 'user', content: 'First message' }], + isSending: true, + sendNow, + }) + mocks.renderer.mockImplementation(({ composer }: { composer: ReactNode }) => composer) + await act(async () => renderHome()) + await act(async () => composerProps().onSendQueuedHead?.()) + expect(sendNow).toHaveBeenCalledExactlyOnceWith() +}) diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index 70ded2007e9..1ea4df9bb9c 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -315,6 +315,9 @@ function OrganizationHomeContent({ isSending={chat.isSending || chat.isReconnecting} onChange={setDraft} onSubmit={submit} + onSendQueuedHead={() => { + void chat.sendNow() + }} onStop={() => { void chat.stopGeneration() }} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.test.tsx index 5387f79ca10..736ebcfc01a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.test.tsx @@ -6,9 +6,10 @@ import { createRoot } from 'react-dom/client' import { expect, it, vi } from 'vitest' import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types' -const { scrollToIndex, renderMessage } = vi.hoisted(() => ({ +const { scrollToIndex, renderMessage, renderInput } = vi.hoisted(() => ({ scrollToIndex: vi.fn(), renderMessage: vi.fn(), + renderInput: vi.fn(), })) /** The measured range still covers the old turn while appended rows await measurement. */ @@ -39,7 +40,10 @@ vi.mock('@tanstack/react-virtual', async (importOriginal) => { }) vi.mock('@/app/workspace/[workspaceId]/components', () => ({ MessageActions: () => null })) vi.mock('@/app/workspace/[workspaceId]/home/components/user-input', () => ({ - UserInput: () => null, + UserInput: (props: { onSendQueuedHead: () => void }) => { + renderInput(props) + return null + }, })) vi.mock('@/app/workspace/[workspaceId]/home/components/queued-messages', () => ({ QueuedMessages: () => null, @@ -231,3 +235,45 @@ it('never restarts the previous response while a new send waits for its deferred vi.unstubAllGlobals() } }) + +it('delegates empty Enter to the live queue sender before the rendered queue updates', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + } + ) + const container = document.createElement('div') + const root = createRoot(container) + const client = new QueryClient() + const sendNow = vi.fn().mockResolvedValue(undefined) + try { + await act(async () => + root.render( + + + + ) + ) + await act(async () => renderInput.mock.lastCall![0].onSendQueuedHead()) + expect(sendNow).toHaveBeenCalledExactlyOnceWith() + } finally { + await act(async () => root.unmount()) + client.clear() + vi.unstubAllGlobals() + } +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index a7496f90db6..7f5c020266b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -80,7 +80,7 @@ interface MothershipChatProps { editingQueuedId: string | null dispatchingHeadId: string | null onRemoveQueuedMessage: (id: string) => void - onSendQueuedMessage: (id: string) => Promise + onSendQueuedMessage: (id?: string) => Promise onEditQueuedMessage: (id: string) => QueuedMessage | undefined onCancelQueueEdit: () => void userId?: string @@ -722,9 +722,8 @@ export function MothershipChat({ }, []) const handleSendQueuedHead = useCallback(() => { - const topMessage = messageQueueRef.current[0] - if (!topMessage) return - void onSendQueuedMessage(topMessage.id) + /** The first Enter can enqueue before this component has rendered the new queue. */ + void onSendQueuedMessage() }, [onSendQueuedMessage]) const handleEditQueued = useCallback( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx index d20e157ae2c..eca74197d0d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx @@ -68,15 +68,18 @@ vi.mock('@/app/workspace/[workspaceId]/home/components/user-input/components', a PromptEditor: ({ editor, placeholder, + onSubmit, }: { editor: PromptEditorInstance placeholder: string + onSubmit: () => void }) => (