mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 15:03:57 +00:00
feat(chat): in-composer stop button to cancel generation (#4103)
This commit is contained in:
@@ -12,6 +12,13 @@ export interface ChatComposerProps {
|
||||
inputValue: string;
|
||||
setInputValue: (value: string | ((prev: string) => string)) => void;
|
||||
onSend: (text?: string) => Promise<void>;
|
||||
/**
|
||||
* Cancel the in-flight generation for the selected thread. When provided, the
|
||||
* Send button morphs into a Stop button while `isSending` is true so the user
|
||||
* can halt the response from inside the composer. When omitted, the Send
|
||||
* button falls back to a disabled spinner during generation.
|
||||
*/
|
||||
onStopGeneration?: () => void;
|
||||
textInputRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
composerInteractionBlocked: boolean;
|
||||
@@ -58,6 +65,7 @@ export default function ChatComposer({
|
||||
inputValue,
|
||||
setInputValue,
|
||||
onSend,
|
||||
onStopGeneration,
|
||||
textInputRef,
|
||||
fileInputRef,
|
||||
composerInteractionBlocked,
|
||||
@@ -91,6 +99,12 @@ export default function ChatComposer({
|
||||
// Show the working spinner only for a normal in-flight send, not while the
|
||||
// composer is intentionally open for follow-up/parallel queueing.
|
||||
const showSendingSpinner = isSending && !allowParallelSend;
|
||||
// During an in-flight turn the primary button becomes a Stop button so the
|
||||
// user can halt generation from the composer — but only while no follow-up is
|
||||
// typed. Once they type (parallel/follow-up send), it reverts to Send so the
|
||||
// follow-up can be queued instead of cancelling the current turn.
|
||||
const hasTypedContent = inputValue.trim().length > 0 || attachments.length > 0;
|
||||
const showStopButton = isSending && !!onStopGeneration && !hasTypedContent;
|
||||
|
||||
// Auto-resize textarea: grow with content, cap at COMPOSER_MAX_HEIGHT, then scroll.
|
||||
useEffect(() => {
|
||||
@@ -219,45 +233,64 @@ export default function ChatComposer({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Send button */}
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-composer-send"
|
||||
data-testid="send-message-button"
|
||||
aria-label={t('chat.send')}
|
||||
title={t('chat.send')}
|
||||
onClick={() => {
|
||||
void onSend();
|
||||
}}
|
||||
disabled={!hasContent || composerLocked}
|
||||
className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary-500 hover:bg-primary-600 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
|
||||
{showSendingSpinner ? (
|
||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
{/* Send / Stop button — while a turn is in flight and a cancel handler
|
||||
is wired, the Send button becomes a Stop button so generation can
|
||||
be halted from inside the composer. Once a follow-up is typed the
|
||||
Send arrow returns so the follow-up can be queued (parallel send)
|
||||
instead of cancelling the current turn. */}
|
||||
{showStopButton ? (
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-composer-stop"
|
||||
data-testid="stop-generation-button"
|
||||
aria-label={t('chat.stopGeneration')}
|
||||
title={t('chat.stopGeneration')}
|
||||
onClick={onStopGeneration}
|
||||
className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary-500 hover:bg-primary-600 text-white transition-colors">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="6" y="6" width="12" height="12" rx="1.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-composer-send"
|
||||
data-testid="send-message-button"
|
||||
aria-label={t('chat.send')}
|
||||
title={t('chat.send')}
|
||||
onClick={() => {
|
||||
void onSend();
|
||||
}}
|
||||
disabled={!hasContent || composerLocked}
|
||||
className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary-500 hover:bg-primary-600 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
|
||||
{showSendingSpinner ? (
|
||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,6 +133,43 @@ describe('ChatComposer', () => {
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the stop button (not send) while sending with an empty composer', () => {
|
||||
renderComposer({ isSending: true, inputValue: '', onStopGeneration: vi.fn() });
|
||||
expect(screen.getByTestId('stop-generation-button')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('send-message-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stop button stays enabled while sending', () => {
|
||||
renderComposer({ isSending: true, inputValue: '', onStopGeneration: vi.fn() });
|
||||
expect(screen.getByTestId('stop-generation-button')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls onStopGeneration when the stop button is clicked', () => {
|
||||
const onStopGeneration = vi.fn();
|
||||
renderComposer({ isSending: true, inputValue: '', onStopGeneration });
|
||||
fireEvent.click(screen.getByTestId('stop-generation-button'));
|
||||
expect(onStopGeneration).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reverts to the send button while sending once a follow-up is typed', () => {
|
||||
// Parallel/follow-up send: a typed follow-up should be queuable, so the
|
||||
// Send arrow returns instead of the Stop button.
|
||||
renderComposer({
|
||||
isSending: true,
|
||||
allowParallelSend: true,
|
||||
inputValue: 'a follow-up',
|
||||
onStopGeneration: vi.fn(),
|
||||
});
|
||||
expect(screen.queryByTestId('stop-generation-button')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('send-message-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the disabled send button while sending when no onStopGeneration', () => {
|
||||
renderComposer({ isSending: true, inputValue: '' });
|
||||
expect(screen.queryByTestId('stop-generation-button')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('send-message-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls onSwitchToMicCloud when voice mode button is clicked', () => {
|
||||
const onSwitchToMicCloud = vi.fn();
|
||||
renderComposer({ onSwitchToMicCloud });
|
||||
|
||||
Reference in New Issue
Block a user