Skip to content

Commit 316fea0

Browse files
authored
fix(chat): restore double Enter and wait for queued handoff settlement (#8237)
1 parent 99b1702 commit 316fea0

14 files changed

Lines changed: 296 additions & 47 deletions

File tree

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ async function render(
193193
requestMode: 'agent' | 'assistant' = 'assistant',
194194
controls: Pick<
195195
ComponentProps<typeof Composer>,
196-
'isSending' | 'showModeSelector' | 'onModeChange' | 'restoredContexts'
196+
'isSending' | 'showModeSelector' | 'onModeChange' | 'restoredContexts' | 'onSendQueuedHead'
197197
> = { isSending: false }
198198
) {
199199
function Harness() {
@@ -215,6 +215,7 @@ async function render(
215215
isInitialView={isInitialView}
216216
isSending={controls.isSending}
217217
onStop={vi.fn()}
218+
onSendQueuedHead={controls.onSendQueuedHead}
218219
onSubmit={(text, contexts) => {
219220
mocks.submit(text, files.attachedFiles)
220221
mocks.contexts(contexts)
@@ -919,3 +920,42 @@ it('offers the advanced models and each model’s supported efforts', async () =
919920
)
920921
).toEqual(['Low', 'Medium', 'High', 'Extra High', 'Max'])
921922
})
923+
924+
it.each([
925+
['agent', false],
926+
['assistant', false],
927+
['agent', true],
928+
['assistant', true],
929+
] as const)(
930+
'queues once then sends immediately on rapid double Enter (%s, attachment: %s)',
931+
async (mode, withAttachment) => {
932+
const sendHead = vi.fn()
933+
await render(false, withAttachment ? '' : 'Use the latest report', mode, {
934+
isSending: true,
935+
onSendQueuedHead: sendHead,
936+
})
937+
if (withAttachment) await paste([new File(['image'], 'follow-up.png', { type: 'image/png' })])
938+
const input = container.querySelector<HTMLTextAreaElement>('[aria-label="Ask Sim"]')!
939+
await act(async () => {
940+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
941+
expect(mocks.submit).toHaveBeenCalledTimes(1)
942+
expect(sendHead).not.toHaveBeenCalled()
943+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
944+
})
945+
expect(mocks.submit).toHaveBeenCalledTimes(1)
946+
expect(sendHead).toHaveBeenCalledExactlyOnceWith()
947+
expect(input.value).toBe('')
948+
}
949+
)
950+
951+
it('does not send a queued head on empty Enter when idle', async () => {
952+
const sendHead = vi.fn()
953+
await render(false, '', 'assistant', { isSending: false, onSendQueuedHead: sendHead })
954+
await act(async () => {
955+
container
956+
.querySelector<HTMLTextAreaElement>('[aria-label="Ask Sim"]')!
957+
.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
958+
})
959+
expect(sendHead).not.toHaveBeenCalled()
960+
expect(mocks.submit).not.toHaveBeenCalled()
961+
})

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ interface ComposerProps {
4646
onChange: (value: string, contexts?: ChatContext[]) => void
4747
restoredContexts?: ChatContext[]
4848
onSubmit: (text: string, contexts?: ChatContext[]) => void
49+
onSendQueuedHead?: () => void
4950
onStop: () => void
5051
}
5152

@@ -65,9 +66,12 @@ export function Composer({
6566
isSending,
6667
onChange,
6768
onSubmit,
69+
onSendQueuedHead,
6870
restoredContexts,
6971
onStop,
7072
}: ComposerProps) {
73+
const attachedFilesRef = useRef(files.attachedFiles)
74+
attachedFilesRef.current = files.attachedFiles
7175
const imagesOnly = requestMode === 'assistant'
7276
const { organization } = useOrganizationContext()
7377
const { data: allWorkspaces = [] } = useWorkspacesQuery(!imagesOnly)
@@ -147,7 +151,15 @@ export function Composer({
147151
const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim'
148152

149153
const submit = () => {
150-
if (!canSubmit) return
154+
if (attachedFilesRef.current.some((file) => file.uploading)) return
155+
const hasPayload =
156+
editor.getValue().trim().length > 0 || attachedFilesRef.current.some((file) => file.key)
157+
if (!hasPayload) {
158+
if (isSending) onSendQueuedHead?.()
159+
return
160+
}
161+
/** Consume attachments synchronously so a second Enter cannot submit them twice. */
162+
attachedFilesRef.current = []
151163
voice.resetTranscript()
152164
const contexts = imagesOnly ? [] : editor.getActiveContexts()
153165
onSubmit(editor.getPlainValue(), contexts.length ? contexts : undefined)

‎apps/sim/app/o/[organizationId]/home/organization-home.test.tsx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,3 +1013,17 @@ it('keeps an attachment-only draft across remounts and clears it when its last f
10131013
).toBeUndefined()
10141014
expect(composerProps().files.attachedFiles).toEqual([])
10151015
})
1016+
1017+
it('wires empty Enter to the live queue sender for an active organization chat', async () => {
1018+
const sendNow = vi.fn()
1019+
mocks.chat.mockReturnValue({
1020+
...mocks.chat(),
1021+
messages: [{ id: 'user-1', role: 'user', content: 'First message' }],
1022+
isSending: true,
1023+
sendNow,
1024+
})
1025+
mocks.renderer.mockImplementation(({ composer }: { composer: ReactNode }) => composer)
1026+
await act(async () => renderHome(<OrganizationHome chatId='chat-a' />))
1027+
await act(async () => composerProps().onSendQueuedHead?.())
1028+
expect(sendNow).toHaveBeenCalledExactlyOnceWith()
1029+
})

‎apps/sim/app/o/[organizationId]/home/organization-home.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,9 @@ function OrganizationHomeContent({
315315
isSending={chat.isSending || chat.isReconnecting}
316316
onChange={setDraft}
317317
onSubmit={submit}
318+
onSendQueuedHead={() => {
319+
void chat.sendNow()
320+
}}
318321
onStop={() => {
319322
void chat.stopGeneration()
320323
}}

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.test.tsx‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import { createRoot } from 'react-dom/client'
66
import { expect, it, vi } from 'vitest'
77
import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types'
88

9-
const { scrollToIndex, renderMessage } = vi.hoisted(() => ({
9+
const { scrollToIndex, renderMessage, renderInput } = vi.hoisted(() => ({
1010
scrollToIndex: vi.fn(),
1111
renderMessage: vi.fn(),
12+
renderInput: vi.fn(),
1213
}))
1314

1415
/** The measured range still covers the old turn while appended rows await measurement. */
@@ -39,7 +40,10 @@ vi.mock('@tanstack/react-virtual', async (importOriginal) => {
3940
})
4041
vi.mock('@/app/workspace/[workspaceId]/components', () => ({ MessageActions: () => null }))
4142
vi.mock('@/app/workspace/[workspaceId]/home/components/user-input', () => ({
42-
UserInput: () => null,
43+
UserInput: (props: { onSendQueuedHead: () => void }) => {
44+
renderInput(props)
45+
return null
46+
},
4347
}))
4448
vi.mock('@/app/workspace/[workspaceId]/home/components/queued-messages', () => ({
4549
QueuedMessages: () => null,
@@ -231,3 +235,45 @@ it('never restarts the previous response while a new send waits for its deferred
231235
vi.unstubAllGlobals()
232236
}
233237
})
238+
239+
it('delegates empty Enter to the live queue sender before the rendered queue updates', async () => {
240+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
241+
vi.stubGlobal(
242+
'ResizeObserver',
243+
class {
244+
observe() {}
245+
disconnect() {}
246+
}
247+
)
248+
const container = document.createElement('div')
249+
const root = createRoot(container)
250+
const client = new QueryClient()
251+
const sendNow = vi.fn().mockResolvedValue(undefined)
252+
try {
253+
await act(async () =>
254+
root.render(
255+
<QueryClientProvider client={client}>
256+
<MothershipChat
257+
messages={[]}
258+
isSending
259+
messageQueue={[]}
260+
onSubmit={vi.fn()}
261+
onStopGeneration={vi.fn()}
262+
onSendQueuedMessage={sendNow}
263+
editingQueuedId={null}
264+
dispatchingHeadId={null}
265+
onRemoveQueuedMessage={vi.fn()}
266+
onEditQueuedMessage={vi.fn()}
267+
onCancelQueueEdit={vi.fn()}
268+
/>
269+
</QueryClientProvider>
270+
)
271+
)
272+
await act(async () => renderInput.mock.lastCall![0].onSendQueuedHead())
273+
expect(sendNow).toHaveBeenCalledExactlyOnceWith()
274+
} finally {
275+
await act(async () => root.unmount())
276+
client.clear()
277+
vi.unstubAllGlobals()
278+
}
279+
})

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ interface MothershipChatProps {
8080
editingQueuedId: string | null
8181
dispatchingHeadId: string | null
8282
onRemoveQueuedMessage: (id: string) => void
83-
onSendQueuedMessage: (id: string) => Promise<void>
83+
onSendQueuedMessage: (id?: string) => Promise<void>
8484
onEditQueuedMessage: (id: string) => QueuedMessage | undefined
8585
onCancelQueueEdit: () => void
8686
userId?: string
@@ -722,9 +722,8 @@ export function MothershipChat({
722722
}, [])
723723

724724
const handleSendQueuedHead = useCallback(() => {
725-
const topMessage = messageQueueRef.current[0]
726-
if (!topMessage) return
727-
void onSendQueuedMessage(topMessage.id)
725+
/** The first Enter can enqueue before this component has rendered the new queue. */
726+
void onSendQueuedMessage()
728727
}, [onSendQueuedMessage])
729728

730729
const handleEditQueued = useCallback(

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.test.tsx‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,18 @@ vi.mock('@/app/workspace/[workspaceId]/home/components/user-input/components', a
6868
PromptEditor: ({
6969
editor,
7070
placeholder,
71+
onSubmit,
7172
}: {
7273
editor: PromptEditorInstance
7374
placeholder: string
75+
onSubmit: () => void
7476
}) => (
7577
<textarea
7678
ref={editor.textareaRef}
7779
value={editor.value}
7880
placeholder={placeholder}
7981
onChange={editor.handleInputChange}
82+
onKeyDown={(event) => editor.handleKeyDown(event, { onSubmit })}
8083
/>
8184
),
8285
SendButton: ({ onSubmit }: { onSubmit: () => void }) => (
@@ -109,7 +112,7 @@ const QUEUED_MESSAGE: QueuedMessage = {
109112
let root: Root | null = null
110113
let container: HTMLDivElement | null = null
111114

112-
function mount() {
115+
function mount(isSending = false, onSendQueuedHead?: () => void) {
113116
const inputRef = createRef<UserInputHandle>()
114117

115118
function Composer() {
@@ -127,7 +130,8 @@ function mount() {
127130
ref={inputRef}
128131
defaultValue='Initial draft'
129132
onSubmit={mockSubmit}
130-
isSending={false}
133+
isSending={isSending}
134+
onSendQueuedHead={onSendQueuedHead}
131135
onStopGeneration={vi.fn()}
132136
/>
133137
</>
@@ -218,3 +222,19 @@ describe('workspace composer', () => {
218222
expect(mockResetTranscript).toHaveBeenCalled()
219223
})
220224
})
225+
226+
it.each([false, true])(
227+
'sends a queued message on the second Enter without resubmitting attachments (%s)',
228+
async (withAttachment) => {
229+
const sendHead = vi.fn()
230+
mount(true, sendHead)
231+
if (withAttachment) await clickButton('Edit queued')
232+
await act(async () => {
233+
textarea().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
234+
textarea().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
235+
})
236+
expect(mockSubmit).toHaveBeenCalledTimes(1)
237+
expect(sendHead).toHaveBeenCalledExactlyOnceWith()
238+
expect(textarea().value).toBe('')
239+
}
240+
)

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
476476
prevSelectedContextsRef.current = []
477477
resetTranscript()
478478
filesRef.current.clearAttachedFiles()
479+
filesRef.current = { ...filesRef.current, attachedFiles: [] }
479480
}, [resetTranscript])
480481

481482
const handleSubmit = useCallback(() => {

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1450,6 +1450,46 @@ describe('useChat remount send recovery', () => {
14501450
}
14511451
)
14521452

1453+
it('sends the live queue head once without waiting for a render, after Stop settles', async () => {
1454+
state.postBehavior = 'task'
1455+
const { getResult } = renderUseChatInChat('chat-a')
1456+
await act(async () => {
1457+
void getResult().sendMessage('Original request')
1458+
})
1459+
await waitFor(() => state.postBodies.length === 1 && getResult().isSending)
1460+
let releaseStop = () => {}
1461+
const stopGate = new Promise<void>((resolve) => {
1462+
releaseStop = resolve
1463+
})
1464+
let stopRequested = false
1465+
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
1466+
if (String(input).includes('/api/copilot/chat/abort')) {
1467+
stopRequested = true
1468+
await stopGate
1469+
}
1470+
return fetchStub(input, init)
1471+
})
1472+
state.postBehavior = 'hang'
1473+
const beforeRender = getResult()
1474+
await act(async () => {
1475+
void beforeRender.sendMessage('Use the latest report')
1476+
void beforeRender.sendNow()
1477+
void beforeRender.sendNow()
1478+
})
1479+
try {
1480+
await waitFor(() => stopRequested)
1481+
expect(state.postBodies).toHaveLength(1)
1482+
} finally {
1483+
await act(async () => {
1484+
releaseStop()
1485+
})
1486+
}
1487+
await waitFor(() => state.postBodies.length === 2)
1488+
expect(state.postBodies[1].message).toBe('Use the latest report')
1489+
expect(allQueuedMessages()).toHaveLength(0)
1490+
expect(state.abortBodies).toHaveLength(1)
1491+
})
1492+
14531493
it('captures Search levels independently for each queued turn and omits it from Build requests', async () => {
14541494
state.postBehavior = 'task'
14551495
const { getResult } = renderUseChat({ organizationId: 'org-a' }, 'assistant')

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ export interface UseChatReturn {
271271
reorderResources: (resources: MothershipResource[]) => void
272272
messageQueue: QueuedMessage[]
273273
removeFromQueue: (id: string) => void
274-
sendNow: (id: string) => Promise<void>
274+
sendNow: (id?: string) => Promise<void>
275275
editQueuedMessage: (id: string) => QueuedMessage | undefined
276276
cancelQueueEdit: () => void
277277
editingQueuedId: string | null
@@ -4784,9 +4784,9 @@ export function useChat(
47844784
}, [])
47854785

47864786
const sendQueuedMessageImmediately = useCallback(
4787-
async (id: string) => {
4787+
async (id?: string) => {
47884788
const queue = useMothershipQueueStore.getState().queues[chatKeyRef.current]
4789-
const msg = queue?.find((queued) => queued.id === id)
4789+
const msg = id === undefined ? queue?.[0] : queue?.find((queued) => queued.id === id)
47904790
if (!msg) return
47914791
if (queuedMessageDispatchIdsRef.current.has(msg.id)) return
47924792
const admissionPending = hasPendingChatAdmission()
@@ -4844,7 +4844,7 @@ export function useChat(
48444844
)
48454845

48464846
const sendNow = useCallback(
4847-
async (id: string) => {
4847+
async (id?: string) => {
48484848
await sendQueuedMessageImmediately(id)
48494849
},
48504850
[sendQueuedMessageImmediately]

0 commit comments

Comments
 (0)