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
Expand Up @@ -193,7 +193,7 @@ async function render(
requestMode: 'agent' | 'assistant' = 'assistant',
controls: Pick<
ComponentProps<typeof Composer>,
'isSending' | 'showModeSelector' | 'onModeChange' | 'restoredContexts'
'isSending' | 'showModeSelector' | 'onModeChange' | 'restoredContexts' | 'onSendQueuedHead'
> = { isSending: false }
) {
function Harness() {
Expand All @@ -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)
Expand Down Expand Up @@ -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<HTMLTextAreaElement>('[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<HTMLTextAreaElement>('[aria-label="Ask Sim"]')!
.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
})
expect(sendHead).not.toHaveBeenCalled()
expect(mocks.submit).not.toHaveBeenCalled()
})
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ interface ComposerProps {
onChange: (value: string, contexts?: ChatContext[]) => void
restoredContexts?: ChatContext[]
onSubmit: (text: string, contexts?: ChatContext[]) => void
onSendQueuedHead?: () => void
onStop: () => void
}

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions apps/sim/app/o/[organizationId]/home/organization-home.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<OrganizationHome chatId='chat-a' />))
await act(async () => composerProps().onSendQueuedHead?.())
expect(sendNow).toHaveBeenCalledExactlyOnceWith()
})
3 changes: 3 additions & 0 deletions apps/sim/app/o/[organizationId]/home/organization-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,9 @@ function OrganizationHomeContent({
isSending={chat.isSending || chat.isReconnecting}
onChange={setDraft}
onSubmit={submit}
onSendQueuedHead={() => {
void chat.sendNow()
}}
onStop={() => {
void chat.stopGeneration()
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
<QueryClientProvider client={client}>
<MothershipChat
messages={[]}
isSending
messageQueue={[]}
onSubmit={vi.fn()}
onStopGeneration={vi.fn()}
onSendQueuedMessage={sendNow}
editingQueuedId={null}
dispatchingHeadId={null}
onRemoveQueuedMessage={vi.fn()}
onEditQueuedMessage={vi.fn()}
onCancelQueueEdit={vi.fn()}
/>
</QueryClientProvider>
)
)
await act(async () => renderInput.mock.lastCall![0].onSendQueuedHead())
expect(sendNow).toHaveBeenCalledExactlyOnceWith()
} finally {
await act(async () => root.unmount())
client.clear()
vi.unstubAllGlobals()
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ interface MothershipChatProps {
editingQueuedId: string | null
dispatchingHeadId: string | null
onRemoveQueuedMessage: (id: string) => void
onSendQueuedMessage: (id: string) => Promise<void>
onSendQueuedMessage: (id?: string) => Promise<void>
onEditQueuedMessage: (id: string) => QueuedMessage | undefined
onCancelQueueEdit: () => void
userId?: string
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}) => (
<textarea
ref={editor.textareaRef}
value={editor.value}
placeholder={placeholder}
onChange={editor.handleInputChange}
onKeyDown={(event) => editor.handleKeyDown(event, { onSubmit })}
/>
),
SendButton: ({ onSubmit }: { onSubmit: () => void }) => (
Expand Down Expand Up @@ -109,7 +112,7 @@ const QUEUED_MESSAGE: QueuedMessage = {
let root: Root | null = null
let container: HTMLDivElement | null = null

function mount() {
function mount(isSending = false, onSendQueuedHead?: () => void) {
const inputRef = createRef<UserInputHandle>()

function Composer() {
Expand All @@ -127,7 +130,8 @@ function mount() {
ref={inputRef}
defaultValue='Initial draft'
onSubmit={mockSubmit}
isSending={false}
isSending={isSending}
onSendQueuedHead={onSendQueuedHead}
onStopGeneration={vi.fn()}
/>
</>
Expand Down Expand Up @@ -218,3 +222,19 @@ describe('workspace composer', () => {
expect(mockResetTranscript).toHaveBeenCalled()
})
})

it.each([false, true])(
'sends a queued message on the second Enter without resubmitting attachments (%s)',
async (withAttachment) => {
const sendHead = vi.fn()
mount(true, sendHead)
if (withAttachment) await clickButton('Edit queued')
await act(async () => {
textarea().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
textarea().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
})
expect(mockSubmit).toHaveBeenCalledTimes(1)
expect(sendHead).toHaveBeenCalledExactlyOnceWith()
expect(textarea().value).toBe('')
}
)
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
prevSelectedContextsRef.current = []
resetTranscript()
filesRef.current.clearAttachedFiles()
filesRef.current = { ...filesRef.current, attachedFiles: [] }
}, [resetTranscript])

const handleSubmit = useCallback(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1450,6 +1450,46 @@ describe('useChat remount send recovery', () => {
}
)

it('sends the live queue head once without waiting for a render, after Stop settles', async () => {
state.postBehavior = 'task'
const { getResult } = renderUseChatInChat('chat-a')
await act(async () => {
void getResult().sendMessage('Original request')
})
await waitFor(() => state.postBodies.length === 1 && getResult().isSending)
let releaseStop = () => {}
const stopGate = new Promise<void>((resolve) => {
releaseStop = resolve
})
let stopRequested = false
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).includes('/api/copilot/chat/abort')) {
stopRequested = true
await stopGate
}
return fetchStub(input, init)
})
state.postBehavior = 'hang'
const beforeRender = getResult()
await act(async () => {
void beforeRender.sendMessage('Use the latest report')
void beforeRender.sendNow()
void beforeRender.sendNow()
})
try {
await waitFor(() => stopRequested)
expect(state.postBodies).toHaveLength(1)
} finally {
await act(async () => {
releaseStop()
})
}
await waitFor(() => state.postBodies.length === 2)
expect(state.postBodies[1].message).toBe('Use the latest report')
expect(allQueuedMessages()).toHaveLength(0)
expect(state.abortBodies).toHaveLength(1)
})

it('captures Search levels independently for each queued turn and omits it from Build requests', async () => {
state.postBehavior = 'task'
const { getResult } = renderUseChat({ organizationId: 'org-a' }, 'assistant')
Expand Down
8 changes: 4 additions & 4 deletions apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ export interface UseChatReturn {
reorderResources: (resources: MothershipResource[]) => void
messageQueue: QueuedMessage[]
removeFromQueue: (id: string) => void
sendNow: (id: string) => Promise<void>
sendNow: (id?: string) => Promise<void>
editQueuedMessage: (id: string) => QueuedMessage | undefined
cancelQueueEdit: () => void
editingQueuedId: string | null
Expand Down Expand Up @@ -4784,9 +4784,9 @@ export function useChat(
}, [])

const sendQueuedMessageImmediately = useCallback(
async (id: string) => {
async (id?: string) => {
const queue = useMothershipQueueStore.getState().queues[chatKeyRef.current]
const msg = queue?.find((queued) => queued.id === id)
const msg = id === undefined ? queue?.[0] : queue?.find((queued) => queued.id === id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Queued edit sent prematurely When someone is editing the first queued message during an active response, they can clear the composer and press Enter. This path sends that message’s original content without checking whether it is being edited, so the correction can go out before the user finishes it. The automatic queue sender already pauses for an edited message, but this immediate-send path bypasses that check; the new organization composer callback exposes the same path there.

if (!msg) return
if (queuedMessageDispatchIdsRef.current.has(msg.id)) return
const admissionPending = hasPendingChatAdmission()
Expand Down Expand Up @@ -4844,7 +4844,7 @@ export function useChat(
)

const sendNow = useCallback(
async (id: string) => {
async (id?: string) => {
await sendQueuedMessageImmediately(id)
},
[sendQueuedMessageImmediately]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ describe('abort authorization before service signaling', () => {
streamId: 'stream',
chatId: 'chat',
userId: 'actor',
timeoutMs: 3000,
timeoutMs: 6000,
})
expect(mocks.permissions).not.toHaveBeenCalled()
})
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/mothership/request/application/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export const abortRun = defineAuthorizedChatUseCase({
streamId,
userId,
chatId,
timeoutMs: 3000,
timeoutMs: 6000,
}).catch((error) => {
logger.warn('Stop saved; worker delivery awaits reconciliation', {
streamId,
Expand Down
Loading
Loading