mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
Prevent duplicate chat sends while pending (#2071)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
0c574bd9ea
commit
544a68239d
@@ -250,6 +250,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
|
||||
const [inlineSuggestionValue, setInlineSuggestionValue] = useState('');
|
||||
const [sendError, setSendError] = useState<ChatSendError | null>(null);
|
||||
const [sendAdvisory, setSendAdvisory] = useState<string | null>(null);
|
||||
const [pendingSendingThreadId, setPendingSendingThreadId] = useState<string | null>(null);
|
||||
const [profileDraftOpen, setProfileDraftOpen] = useState(false);
|
||||
const [profileDraft, setProfileDraft] = useState(DEFAULT_PROFILE_DRAFT);
|
||||
const socketStatus = useAppSelector(selectSocketStatus);
|
||||
@@ -316,6 +317,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
|
||||
|
||||
const textInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isComposingTextRef = useRef(false);
|
||||
const pendingSendRef = useRef<string | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
@@ -545,6 +547,8 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
|
||||
dispatch(setActiveThread(null));
|
||||
sendingTimeoutRef.current = null;
|
||||
sendingThreadIdRef.current = null;
|
||||
pendingSendRef.current = null;
|
||||
setPendingSendingThreadId(null);
|
||||
}, 120_000);
|
||||
};
|
||||
|
||||
@@ -668,6 +672,8 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
|
||||
};
|
||||
|
||||
const handleSendMessage = async (text?: string) => {
|
||||
if (pendingSendRef.current) return;
|
||||
|
||||
const normalized = text ?? inputValue;
|
||||
const trimmedInput = normalized.trim();
|
||||
|
||||
@@ -707,6 +713,8 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
|
||||
|
||||
const sendingThreadId = selectedThreadId;
|
||||
if (!sendingThreadId) return;
|
||||
pendingSendRef.current = sendingThreadId;
|
||||
setPendingSendingThreadId(sendingThreadId);
|
||||
const userMessage: ThreadMessage = {
|
||||
id: `msg_${globalThis.crypto.randomUUID()}`,
|
||||
content: trimmed,
|
||||
@@ -725,10 +733,14 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
|
||||
// unrelated errors whose `.toString()` happens to equal the sentinel.
|
||||
if (error === THREAD_NOT_FOUND_MESSAGE) {
|
||||
setSendError(null);
|
||||
pendingSendRef.current = null;
|
||||
setPendingSendingThreadId(null);
|
||||
return;
|
||||
}
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
setSendError(chatSendError('cloud_send_failed', msg));
|
||||
pendingSendRef.current = null;
|
||||
setPendingSendingThreadId(null);
|
||||
return;
|
||||
}
|
||||
setInputValue('');
|
||||
@@ -757,6 +769,11 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
|
||||
locale: uiLocale,
|
||||
});
|
||||
trackEvent('chat_message_sent');
|
||||
// Backend accepted the send; lifecycle ('started' → 'streaming') now
|
||||
// owns the `isSending` UI lock. Release the pending guard so the next
|
||||
// user turn isn't blocked by a stale ref/state.
|
||||
pendingSendRef.current = null;
|
||||
setPendingSendingThreadId(null);
|
||||
|
||||
// Active-thread reset happens in the global ChatRuntimeProvider events.
|
||||
} catch (err) {
|
||||
@@ -780,6 +797,8 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
|
||||
}
|
||||
dispatch(clearRuntimeForThread({ threadId: sendingThreadId }));
|
||||
dispatch(setActiveThread(null));
|
||||
pendingSendRef.current = null;
|
||||
setPendingSendingThreadId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1079,7 +1098,8 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
|
||||
}, [selectedThreadId, composerInteractionBlocked, inputMode]);
|
||||
const isSending = Boolean(
|
||||
selectedThreadId &&
|
||||
(inferenceTurnLifecycleByThread[selectedThreadId] === 'started' ||
|
||||
(pendingSendingThreadId === selectedThreadId ||
|
||||
inferenceTurnLifecycleByThread[selectedThreadId] === 'started' ||
|
||||
inferenceTurnLifecycleByThread[selectedThreadId] === 'streaming')
|
||||
);
|
||||
const shouldRenderTimelineBeforeLatestAgentMessage =
|
||||
@@ -1972,7 +1992,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
|
||||
// Without `!selectedThreadId`, a mic submit before a thread is
|
||||
// ready hits `handleSendMessage`'s early return and the
|
||||
// transcript is silently dropped — the user spoke into the void.
|
||||
disabled={composerInteractionBlocked || !selectedThreadId}
|
||||
disabled={composerInteractionBlocked || isSending || !selectedThreadId}
|
||||
onSubmit={text => handleSendMessage(text)}
|
||||
onError={message => setSendError(chatSendError('voice_transcription', message))}
|
||||
showDeviceSelector
|
||||
@@ -2001,7 +2021,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={t('chat.typeMessage')}
|
||||
rows={1}
|
||||
disabled={composerInteractionBlocked}
|
||||
disabled={composerInteractionBlocked || isSending}
|
||||
className="relative z-10 w-full resize-none border-0 bg-transparent pl-4 pr-10 py-2.5 text-sm leading-normal whitespace-pre-wrap break-words font-sans text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 outline-none focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0 max-h-32 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
{/* Voice input mic hidden per #717 (inputMode='voice' path retained). */}
|
||||
@@ -2012,7 +2032,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
|
||||
onClick={() => {
|
||||
void handleSendMessage();
|
||||
}}
|
||||
disabled={!inputValue.trim() || composerInteractionBlocked}
|
||||
disabled={!inputValue.trim() || composerInteractionBlocked || isSending}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-primary-500 hover:bg-primary-600 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex-shrink-0">
|
||||
{isSending ? (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
|
||||
@@ -16,6 +16,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { agentProfilesApi } from '../../services/api/agentProfilesApi';
|
||||
import { threadApi } from '../../services/api/threadApi';
|
||||
import { chatSend } from '../../services/chatService';
|
||||
import { CoreRpcError } from '../../services/coreRpcClient';
|
||||
import agentProfileReducer from '../../store/agentProfileSlice';
|
||||
import chatRuntimeReducer from '../../store/chatRuntimeSlice';
|
||||
import socketReducer from '../../store/socketSlice';
|
||||
@@ -646,6 +647,166 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks duplicate sends while the first send is still pending', async () => {
|
||||
let resolveSend: (() => void) | undefined;
|
||||
vi.mocked(chatSend).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>(resolve => {
|
||||
resolveSend = resolve;
|
||||
})
|
||||
);
|
||||
const { textarea, thread } = await renderSelectedConversation();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: 'slow backend' } });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(textarea).toHaveValue('slow backend');
|
||||
expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
const sendButton = screen.getByRole('button', { name: 'Send message' });
|
||||
await act(async () => {
|
||||
fireEvent.click(sendButton);
|
||||
fireEvent.click(sendButton);
|
||||
fireEvent.click(sendButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(chatSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(threadApi.appendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(chatSend).toHaveBeenCalledWith({
|
||||
threadId: thread.id,
|
||||
message: 'slow backend',
|
||||
model: 'reasoning-v1',
|
||||
profileId: 'default',
|
||||
locale: 'en',
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Send message' })).toBeDisabled();
|
||||
resolveSend?.();
|
||||
});
|
||||
|
||||
it('releases the pending-send lock when appendMessage rejects with a generic error', async () => {
|
||||
vi.mocked(threadApi.appendMessage).mockRejectedValueOnce(new Error('disk full'));
|
||||
const { textarea } = await renderSelectedConversation();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: 'will fail locally' } });
|
||||
});
|
||||
const sendButton = screen.getByRole('button', { name: 'Send message' });
|
||||
await act(async () => {
|
||||
fireEvent.click(sendButton);
|
||||
});
|
||||
|
||||
// chatSend never runs because the local append failed first.
|
||||
await waitFor(() => {
|
||||
expect(threadApi.appendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(chatSend).not.toHaveBeenCalled();
|
||||
|
||||
// Pending guard released: the user can re-enter text and the send button
|
||||
// enables again.
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: 'retry' } });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('releases the pending-send lock when appendMessage hits a stale-thread error', async () => {
|
||||
vi.mocked(threadApi.appendMessage).mockRejectedValueOnce(
|
||||
new CoreRpcError('thread missing', 'thread_not_found')
|
||||
);
|
||||
const { textarea } = await renderSelectedConversation();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: 'stale thread send' } });
|
||||
});
|
||||
const sendButton = screen.getByRole('button', { name: 'Send message' });
|
||||
await act(async () => {
|
||||
fireEvent.click(sendButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(threadApi.appendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(chatSend).not.toHaveBeenCalled();
|
||||
|
||||
// Stale-thread branch silently clears the guard; typing must re-enable Send.
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: 'retry' } });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the pending guard when the 120s silence timer fires', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const { textarea } = await renderSelectedConversation();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: 'hang the backend' } });
|
||||
});
|
||||
const sendButton = screen.getByRole('button', { name: 'Send message' });
|
||||
await act(async () => {
|
||||
fireEvent.click(sendButton);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(chatSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Fast-forward past the 120s silence window with no inference signals.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
});
|
||||
|
||||
// After the safety timeout, typing should re-enable Send — proves the
|
||||
// pending guard was reset inside the timeout callback.
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: 'retry after timeout' } });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('releases the pending-send lock when chatSend rejects', async () => {
|
||||
vi.mocked(chatSend).mockRejectedValueOnce(new Error('emit failed'));
|
||||
const { textarea } = await renderSelectedConversation();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: 'doomed send' } });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(textarea).toHaveValue('doomed send');
|
||||
});
|
||||
|
||||
const sendButton = screen.getByRole('button', { name: 'Send message' });
|
||||
await act(async () => {
|
||||
fireEvent.click(sendButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(chatSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// After the failed send, typing again should leave the composer enabled so
|
||||
// the user can retry — proves the pending guard was released.
|
||||
await act(async () => {
|
||||
fireEvent.change(textarea, { target: { value: 'retry send' } });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a custom agent profile from the header draft form', async () => {
|
||||
const thread = makeThread({ id: 'profile-thread', title: 'Profile Thread' });
|
||||
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
|
||||
|
||||
Reference in New Issue
Block a user