diff --git a/frontend/src/components/agent/AgentQuickSelect.tsx b/frontend/src/components/agent/AgentQuickSelect.tsx index e10f8d635..56f38aacc 100644 --- a/frontend/src/components/agent/AgentQuickSelect.tsx +++ b/frontend/src/components/agent/AgentQuickSelect.tsx @@ -55,7 +55,7 @@ export function AgentQuickSelect({ const buttonContent = ( diff --git a/frontend/src/components/message/ComposerToolsMenu.test.tsx b/frontend/src/components/message/ComposerToolsMenu.test.tsx index c9cfc6de1..02e754978 100644 --- a/frontend/src/components/message/ComposerToolsMenu.test.tsx +++ b/frontend/src/components/message/ComposerToolsMenu.test.tsx @@ -249,12 +249,12 @@ describe('ComposerToolsMenu', () => { expect(props.onAttachFile).toHaveBeenCalledTimes(1) }) - it('uses the 40px control size for the trigger', () => { + it('uses the 44px control size for the trigger', () => { mockPermissionMode() renderMenu() const trigger = screen.getByRole('button', { name: 'Composer options' }) - expect(trigger).toHaveClass('size-10', 'p-0') + expect(trigger).toHaveClass('size-11', 'p-0') expect(trigger).not.toHaveClass('p-2') }) }) diff --git a/frontend/src/components/message/ComposerToolsMenu.tsx b/frontend/src/components/message/ComposerToolsMenu.tsx index a8609e233..679004dda 100644 --- a/frontend/src/components/message/ComposerToolsMenu.tsx +++ b/frontend/src/components/message/ComposerToolsMenu.tsx @@ -56,7 +56,7 @@ export function ComposerToolsMenu({ type="button" aria-label="Composer options" title="Composer options" - className={cn(getIconToggleButtonClassName(permission.isAuto || sandbox.isSandboxed || goalArmed), 'flex items-center justify-center size-10 p-0')} + className={cn(getIconToggleButtonClassName(permission.isAuto || sandbox.isSandboxed || goalArmed), 'flex items-center justify-center size-11 p-0')} > diff --git a/frontend/src/components/message/PromptInput.composer.test.tsx b/frontend/src/components/message/PromptInput.composer.test.tsx index 0817021f5..6185eed5e 100644 --- a/frontend/src/components/message/PromptInput.composer.test.tsx +++ b/frontend/src/components/message/PromptInput.composer.test.tsx @@ -291,7 +291,7 @@ describe('PromptInput composer layout', () => { renderComponent({ showScrollButton: true, onScrollToBottom }) const arrow = screen.getByRole('button', { name: 'Scroll to bottom' }) - expect(arrow).toHaveClass('size-10', 'p-0') + expect(arrow).toHaveClass('size-11', 'p-0') fireEvent.click(arrow) @@ -359,18 +359,18 @@ describe('PromptInput composer layout', () => { }) describe('control sizing', () => { - it('keeps the mic at 40px before and after the prompt gains focus on mobile', () => { + it('keeps the mic at 44px before and after the prompt gains focus on mobile', () => { mocks.useMobile.mockReturnValue(true) renderComponent() const micBeforeFocus = mobileVoiceButton() as HTMLElement - expect(micBeforeFocus).toHaveClass('min-h-10', 'min-w-10') + expect(micBeforeFocus).toHaveClass('min-h-11', 'min-w-11') expect(micBeforeFocus).not.toHaveClass('min-h-[44px]') fireEvent.focus(promptInput()) const micAfterFocus = mobileVoiceButton() as HTMLElement - expect(micAfterFocus).toHaveClass('min-h-10', 'min-w-10') + expect(micAfterFocus).toHaveClass('min-h-11', 'min-w-11') expect(micAfterFocus).not.toHaveClass('min-h-[44px]') }) diff --git a/frontend/src/components/message/PromptInput.stt.test.tsx b/frontend/src/components/message/PromptInput.stt.test.tsx index ce6fbcd4d..00c1c63dc 100644 --- a/frontend/src/components/message/PromptInput.stt.test.tsx +++ b/frontend/src/components/message/PromptInput.stt.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' +import { render, screen, fireEvent, act, waitFor, within } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput } from './PromptInput' import { createCommandActionsMock } from '@/test/test-utils' @@ -466,7 +466,7 @@ describe('PromptInput STT Gesture Tests', () => { }) }) - it('pointer down while recording sets up swipe gesture', async () => { + it('pointer down while recording starts a drag gesture', async () => { renderComponent({ isRecording: true }) const container = getMobileVoiceButtonContainer() @@ -588,4 +588,190 @@ describe('PromptInput STT Gesture Tests', () => { expect(queryMobileVoiceButton()).not.toBeNull() }) }) + + describe('in-row mobile voice recorder', () => { + const renderRecorder = (sttOverrides: Partial = {}) => { + const queryClient = createTestQueryClient() + const buildTree = (overrides: Partial) => { + mocks.useSTT.mockReturnValue({ + isRecording: false, + isProcessing: false, + isSupported: true, + isEnabled: true, + interimTranscript: '', + transcript: '', + startRecording: mockStartRecording, + stopRecording: mockStopRecording, + abortRecording: mockAbortRecording, + reset: mockReset, + clear: mockClear, + ...overrides, + } as unknown as MockSTTReturn) + return ( + + defaultProps.onPromptChange(hasContent)} /> + + ) + } + + const view = render(buildTree(sttOverrides)) + return { rerender: (overrides: Partial) => view.rerender(buildTree(overrides)) } + } + + const getControlsRow = () => screen.getByTestId('composer-controls-row') + + it('swaps the controls row for discard, strip, mic and send while recording', () => { + renderComponent({ isRecording: true }) + + const row = getControlsRow() + + expect(within(row).getByRole('button', { name: 'Discard recording' })).toBeInTheDocument() + expect(within(row).getByRole('button', { name: 'Send recording' })).toBeInTheDocument() + expect(within(row).getByText('0:00')).toBeInTheDocument() + expect(within(row).getByRole('status')).toHaveTextContent('‹ discard · send ›') + }) + + it('hides the mobile insert keys and renders nothing above the mic', () => { + renderComponent({ isRecording: true }) + + fireEvent.focus(screen.getByPlaceholderText('Send a message...')) + + expect(screen.queryByRole('toolbar', { name: 'Insert' })).not.toBeInTheDocument() + expect(getMobileVoiceButtonContainer().children).toHaveLength(1) + }) + + it('stops recording and marks the transcript for auto-send when send is tapped', async () => { + const view = renderRecorder({ isRecording: true }) + + fireEvent.click(screen.getByRole('button', { name: 'Send recording' })) + + expect(mockStopRecording).toHaveBeenCalledTimes(1) + + view.rerender({ isRecording: false, isProcessing: true }) + + expect(screen.getByText('Transcribing & sending…')).toBeInTheDocument() + + view.rerender({ isRecording: false, isProcessing: false, transcript: 'hello there' }) + + await waitFor(() => { + const [variables] = mocks.useSendPromptMutate.mock.calls[0] as [{ text: string }] + expect(variables.text).toBe('hello there') + }) + }) + + it('upgrades a transcription in progress to send when send is tapped', async () => { + const view = renderRecorder({ isRecording: false, isProcessing: true }) + + expect(screen.getByText('Transcribing…')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Send recording' })) + + expect(mockStopRecording).not.toHaveBeenCalled() + + view.rerender({ isRecording: false, isProcessing: false, transcript: 'upgraded text' }) + + await waitFor(() => { + const [variables] = mocks.useSendPromptMutate.mock.calls[0] as [{ text: string }] + expect(variables.text).toBe('upgraded text') + }) + }) + + it('aborts recording when discard is tapped', () => { + renderComponent({ isRecording: true }) + mockAbortRecording.mockClear() + + const discardButton = screen.getByRole('button', { name: 'Discard recording' }) + + fireEvent.pointerDown(discardButton) + fireEvent.click(discardButton) + + expect(mockAbortRecording).toHaveBeenCalledTimes(1) + expect(mockStopRecording).not.toHaveBeenCalled() + }) + + it('arms send on a right drag and sends on release', async () => { + renderComponent({ isRecording: true }) + + const container = getMobileVoiceButtonContainer() + + await act(async () => { + fireEvent.pointerDown(container, { clientX: 100 }) + fireEvent.pointerMove(container, { clientX: 130 }) + }) + + expect(screen.getByText('Release to send')).toBeInTheDocument() + expect(mockStopRecording).not.toHaveBeenCalled() + + await act(async () => { + fireEvent.pointerUp(container, { clientX: 130 }) + }) + + expect(mockStopRecording).toHaveBeenCalledTimes(1) + }) + + it('arms discard on a left drag past the threshold and aborts on release', async () => { + renderComponent({ isRecording: true }) + mockAbortRecording.mockClear() + + const container = getMobileVoiceButtonContainer() + + await act(async () => { + fireEvent.pointerDown(container, { clientX: 200 }) + fireEvent.pointerMove(container, { clientX: 100 }) + }) + + expect(screen.getByText('Release to discard')).toBeInTheDocument() + + await act(async () => { + fireEvent.pointerUp(container, { clientX: 100 }) + }) + + expect(mockAbortRecording).toHaveBeenCalledTimes(1) + expect(mockStopRecording).not.toHaveBeenCalled() + }) + + it('does not restart recording when the mic is tapped right after a drag-to-discard release', async () => { + const view = renderRecorder({ isRecording: true }) + mockAbortRecording.mockClear() + mockStartRecording.mockClear() + + const container = getMobileVoiceButtonContainer() + + await act(async () => { + fireEvent.pointerDown(container, { clientX: 200 }) + fireEvent.pointerMove(container, { clientX: 100 }) + }) + + expect(screen.getByText('Release to discard')).toBeInTheDocument() + + await act(async () => { + fireEvent.pointerUp(container, { clientX: 100 }) + }) + + expect(mockAbortRecording).toHaveBeenCalledTimes(1) + + view.rerender({ isRecording: false }) + + expect(screen.queryByRole('button', { name: 'Discard recording' })).not.toBeInTheDocument() + + await act(async () => { + fireEvent.click(getMobileVoiceButton()) + await new Promise(resolve => setTimeout(resolve, 10)) + }) + + expect(mockStartRecording).not.toHaveBeenCalled() + expect(screen.queryByRole('button', { name: 'Discard recording' })).not.toBeInTheDocument() + }) + + it('leaves the desktop composer without the recorder row', () => { + mocks.useMobile.mockReturnValue(false) + + renderComponent({ isRecording: true }) + + expect(screen.queryByRole('button', { name: 'Discard recording' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Send recording' })).not.toBeInTheDocument() + expect(screen.queryByText('Release to send')).not.toBeInTheDocument() + expect(document.querySelector('[data-voice-variant="desktop"]')).not.toBeNull() + }) + }) }) diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index df0be4e56..9359eca09 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -19,7 +19,7 @@ import { FINE_POINTER_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' import { usePermissions } from '@/contexts/EventContext' import { isLocalOpenCodeServer, useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' -import { ArrowDown, Upload, X, Mic, MicOff, Target } from 'lucide-react' +import { ArrowDown, ArrowUp, Upload, X, Mic, MicOff, Target } from 'lucide-react' import { SquareFill } from '@/components/ui/square-fill' import { IconToggleButton, getIconToggleButtonClassName } from '@/components/ui/icon-toggle-button' @@ -28,7 +28,7 @@ import { PromptSuggestions, type PromptSuggestion } from './PromptSuggestions' import { ComposerActivityBar } from './ComposerActivityBar' import { ModelQuickSelect } from '@/components/model/ModelQuickSelect' import { AgentQuickSelect } from '@/components/agent/AgentQuickSelect' -import { VoiceStatusOverlay, type VoiceStatusOverlayState } from './VoiceStatusOverlay' +import { VoiceRecorderStrip, type VoiceRecorderStripState } from './VoiceRecorderStrip' import { PermissionModeToggle } from '@/components/session/PermissionModeToggle' import { SessionSandboxMenu } from '@/components/session/SessionSandboxMenu' import { ComposerToolsMenu } from './ComposerToolsMenu' @@ -104,8 +104,11 @@ const COMPOSER_MODE_STYLES: Record ranges.map(([start, end]) => [start + offset, end + offset]) -const VOICE_SEND_SWIPE_ARM_THRESHOLD = 24 -const VOICE_SEND_SWIPE_DISARM_THRESHOLD = 8 +const VOICE_SEND_DRAG_ARM_THRESHOLD = 24 +const VOICE_SEND_DRAG_DISARM_THRESHOLD = 8 +const VOICE_CANCEL_DRAG_ARM_THRESHOLD = 96 +const VOICE_CANCEL_DRAG_DISARM_THRESHOLD = VOICE_CANCEL_DRAG_ARM_THRESHOLD - 8 +const VOICE_CLICK_SUPPRESSION_MS = 400 type VoiceButtonVariant = 'desktop' | 'mobile' @@ -156,15 +159,18 @@ export const PromptInput = memo(forwardRef( const [selectedCommandIndex, setSelectedCommandIndex] = useState(0) const [localMode, setLocalMode] = useState(null) const [isTogglingRecording, setIsTogglingRecording] = useState(false) - const [isVoiceSwipeArmed, setIsVoiceSwipeArmed] = useState(false) + const [isVoiceSendArmed, setIsVoiceSendArmed] = useState(false) + const [isVoiceCancelArmed, setIsVoiceCancelArmed] = useState(false) const [isVoiceAutoSendPending, setIsVoiceAutoSendPending] = useState(false) const [isVoiceAutoSendWaitingForTranscript, setIsVoiceAutoSendWaitingForTranscript] = useState(false) + const [voiceRecordingStartedAt, setVoiceRecordingStartedAt] = useState(null) const [isPromptFocused, setIsPromptFocused] = useState(false) const promptBlurTimerRef = useRef | null>(null) const pendingCommandSubmitRef = useRef(false) const lastAddedTranscriptRef = useRef('') - const voiceGestureStartYRef = useRef(null) - const voiceSwipeArmedRef = useRef(false) + const voiceGestureStartXRef = useRef(null) + const voiceSendArmedRef = useRef(false) + const voiceCancelArmedRef = useRef(false) const pendingVoiceAutoSubmitRef = useRef(false) const ignoreVoiceClickUntilRef = useRef(0) const voiceStartRequestRef = useRef(0) @@ -189,7 +195,9 @@ export const PromptInput = memo(forwardRef( const textareaRef = useRef(null) const fileInputRef = useRef(null) const voiceButtonContainerRef = useRef(null) + const composerControlsRowRef = useRef(null) const hasFinePointer = useMediaQuery(FINE_POINTER_MEDIA_QUERY) + const isMobile = useMobile() useEffect(() => { if (!hasFinePointer) return @@ -199,11 +207,13 @@ export const PromptInput = memo(forwardRef( }, [hasFinePointer, sessionID]) const resetVoiceGestureState = useCallback(() => { - voiceGestureStartYRef.current = null - voiceSwipeArmedRef.current = false + voiceGestureStartXRef.current = null + voiceSendArmedRef.current = false + voiceCancelArmedRef.current = false pendingVoiceAutoSubmitRef.current = false ignoreVoiceClickUntilRef.current = 0 - setIsVoiceSwipeArmed(false) + setIsVoiceSendArmed(false) + setIsVoiceCancelArmed(false) setIsVoiceAutoSendPending(false) setIsVoiceAutoSendWaitingForTranscript(false) }, []) @@ -617,8 +627,10 @@ export const PromptInput = memo(forwardRef( pendingVoiceAutoSubmitRef.current = false setIsVoiceAutoSendPending(false) setIsVoiceAutoSendWaitingForTranscript(false) - setIsVoiceSwipeArmed(false) - voiceSwipeArmedRef.current = false + setIsVoiceSendArmed(false) + voiceSendArmedRef.current = false + setIsVoiceCancelArmed(false) + voiceCancelArmedRef.current = false setIsTogglingRecording(true) const started = await startRecording() @@ -650,8 +662,10 @@ export const PromptInput = memo(forwardRef( pendingVoiceAutoSubmitRef.current = false setIsVoiceAutoSendPending(false) setIsVoiceAutoSendWaitingForTranscript(false) - setIsVoiceSwipeArmed(false) - voiceSwipeArmedRef.current = false + setIsVoiceSendArmed(false) + voiceSendArmedRef.current = false + setIsVoiceCancelArmed(false) + voiceCancelArmedRef.current = false stopRecording() return } @@ -659,15 +673,54 @@ export const PromptInput = memo(forwardRef( await startVoiceRecording() } + const cancelVoiceInput = useCallback(() => { + voiceStartRequestRef.current += 1 + resetVoiceGestureState() + setIsTogglingRecording(false) + + if (isRecording || isTogglingRecording || isProcessing) { + abortRecording() + } + }, [abortRecording, isProcessing, isRecording, isTogglingRecording, resetVoiceGestureState]) + + const clearVoiceGestureArmedState = useCallback(() => { + ignoreVoiceClickUntilRef.current = Date.now() + VOICE_CLICK_SUPPRESSION_MS + voiceSendArmedRef.current = false + voiceCancelArmedRef.current = false + setIsVoiceSendArmed(false) + setIsVoiceCancelArmed(false) + }, []) + + const armVoiceAutoSend = useCallback(() => { + pendingVoiceAutoSubmitRef.current = true + setIsVoiceAutoSendPending(true) + setIsVoiceAutoSendWaitingForTranscript(true) + + if (typeof navigator !== 'undefined' && navigator.vibrate) { + navigator.vibrate([10, 20, 10]) + } + }, []) + + const handleVoiceSendRequest = useCallback(() => { + clearVoiceGestureArmedState() + armVoiceAutoSend() + + if (isRecording) { + stopRecording() + } + }, [armVoiceAutoSend, clearVoiceGestureArmedState, isRecording, stopRecording]) + const handleVoicePointerDown = (event: ReactPointerEvent) => { if ((event.pointerType === 'mouse' && event.button !== 0) || isProcessing || !isRecording) { return } event.preventDefault() - voiceGestureStartYRef.current = event.clientY - voiceSwipeArmedRef.current = false - setIsVoiceSwipeArmed(false) + voiceGestureStartXRef.current = event.clientX + voiceSendArmedRef.current = false + voiceCancelArmedRef.current = false + setIsVoiceSendArmed(false) + setIsVoiceCancelArmed(false) if (event.currentTarget.setPointerCapture) { event.currentTarget.setPointerCapture(event.pointerId) @@ -675,21 +728,33 @@ export const PromptInput = memo(forwardRef( } const handleVoicePointerMove = (event: ReactPointerEvent) => { - if (!isRecording || voiceGestureStartYRef.current === null) { + if (!isRecording || voiceGestureStartXRef.current === null) { return } event.preventDefault() - const deltaY = voiceGestureStartYRef.current - event.clientY - const nextIsSwipeArmed = voiceSwipeArmedRef.current - ? deltaY >= VOICE_SEND_SWIPE_DISARM_THRESHOLD - : deltaY >= VOICE_SEND_SWIPE_ARM_THRESHOLD + const deltaX = event.clientX - voiceGestureStartXRef.current + const nextIsSendArmed = voiceSendArmedRef.current + ? deltaX >= VOICE_SEND_DRAG_DISARM_THRESHOLD + : deltaX >= VOICE_SEND_DRAG_ARM_THRESHOLD + const nextIsCancelArmed = !nextIsSendArmed && (voiceCancelArmedRef.current + ? deltaX <= -VOICE_CANCEL_DRAG_DISARM_THRESHOLD + : deltaX <= -VOICE_CANCEL_DRAG_ARM_THRESHOLD) + + if (nextIsSendArmed !== voiceSendArmedRef.current) { + voiceSendArmedRef.current = nextIsSendArmed + setIsVoiceSendArmed(nextIsSendArmed) + + if (nextIsSendArmed && typeof navigator !== 'undefined' && navigator.vibrate) { + navigator.vibrate(10) + } + } - if (nextIsSwipeArmed !== voiceSwipeArmedRef.current) { - voiceSwipeArmedRef.current = nextIsSwipeArmed - setIsVoiceSwipeArmed(nextIsSwipeArmed) + if (nextIsCancelArmed !== voiceCancelArmedRef.current) { + voiceCancelArmedRef.current = nextIsCancelArmed + setIsVoiceCancelArmed(nextIsCancelArmed) - if (nextIsSwipeArmed && typeof navigator !== 'undefined' && navigator.vibrate) { + if (nextIsCancelArmed && typeof navigator !== 'undefined' && navigator.vibrate) { navigator.vibrate(10) } } @@ -700,17 +765,18 @@ export const PromptInput = memo(forwardRef( event.currentTarget.releasePointerCapture(event.pointerId) } - if (voiceGestureStartYRef.current === null) { + if (voiceGestureStartXRef.current === null) { return } event.preventDefault() - voiceGestureStartYRef.current = null + voiceGestureStartXRef.current = null - if (canceled || !voiceSwipeArmedRef.current) { - ignoreVoiceClickUntilRef.current = Date.now() + 400 - voiceSwipeArmedRef.current = false - setIsVoiceSwipeArmed(false) + const wasSendArmed = voiceSendArmedRef.current + const wasCancelArmed = voiceCancelArmedRef.current + clearVoiceGestureArmedState() + + if (canceled || (!wasSendArmed && !wasCancelArmed)) { pendingVoiceAutoSubmitRef.current = false setIsVoiceAutoSendPending(false) setIsVoiceAutoSendWaitingForTranscript(false) @@ -718,30 +784,15 @@ export const PromptInput = memo(forwardRef( return } - ignoreVoiceClickUntilRef.current = Date.now() + 400 - voiceSwipeArmedRef.current = false - setIsVoiceSwipeArmed(false) - pendingVoiceAutoSubmitRef.current = true - setIsVoiceAutoSendPending(true) - setIsVoiceAutoSendWaitingForTranscript(true) - - if (typeof navigator !== 'undefined' && navigator.vibrate) { - navigator.vibrate([10, 20, 10]) + if (wasCancelArmed) { + cancelVoiceInput() + ignoreVoiceClickUntilRef.current = Date.now() + VOICE_CLICK_SUPPRESSION_MS + return } - stopRecording() + handleVoiceSendRequest() } - const cancelVoiceInput = useCallback(() => { - voiceStartRequestRef.current += 1 - resetVoiceGestureState() - setIsTogglingRecording(false) - - if (isRecording || isTogglingRecording || isProcessing) { - abortRecording() - } - }, [abortRecording, isProcessing, isRecording, isTogglingRecording, resetVoiceGestureState]) - useEffect(() => { const isVoiceFeedbackVisible = isRecording || isTogglingRecording || isProcessing || isVoiceAutoSendPending @@ -760,11 +811,17 @@ export const PromptInput = memo(forwardRef( return } + if (isMobile && composerControlsRowRef.current?.contains(target)) { + return + } + if (isRecording) { - voiceGestureStartYRef.current = null - voiceSwipeArmedRef.current = false + voiceGestureStartXRef.current = null + voiceSendArmedRef.current = false + voiceCancelArmedRef.current = false pendingVoiceAutoSubmitRef.current = false - setIsVoiceSwipeArmed(false) + setIsVoiceSendArmed(false) + setIsVoiceCancelArmed(false) setIsVoiceAutoSendPending(false) setIsVoiceAutoSendWaitingForTranscript(false) stopRecording() @@ -786,7 +843,7 @@ export const PromptInput = memo(forwardRef( return () => { document.removeEventListener('pointerdown', handleOutsidePointerDown, true) } - }, [cancelVoiceInput, isProcessing, isRecording, isTogglingRecording, isVoiceAutoSendPending, stopRecording]) + }, [cancelVoiceInput, isMobile, isProcessing, isRecording, isTogglingRecording, isVoiceAutoSendPending, stopRecording]) useEffect(() => { const textToUse = transcript || interimTranscript @@ -821,6 +878,10 @@ export const PromptInput = memo(forwardRef( } }, [isTogglingRecording]) + useEffect(() => { + setVoiceRecordingStartedAt(isRecording ? Date.now() : null) + }, [isRecording]) + useEffect(() => { if (!pendingVoiceAutoSubmitRef.current || isVoiceAutoSendWaitingForTranscript || isRecording || isProcessing || !prompt.trim()) { return @@ -832,6 +893,14 @@ export const PromptInput = memo(forwardRef( handleSubmitRef.current() }, [prompt, isRecording, isProcessing, isVoiceAutoSendWaitingForTranscript]) + useEffect(() => { + if (!isVoiceAutoSendWaitingForTranscript || isRecording || isProcessing || !transcript.trim()) { + return + } + + setIsVoiceAutoSendWaitingForTranscript(false) + }, [isRecording, isProcessing, transcript, isVoiceAutoSendWaitingForTranscript]) + useEffect(() => { if (pendingVoiceAutoSubmitRef.current && !isRecording && !isProcessing && !transcript.trim() && !interimTranscript.trim()) { pendingVoiceAutoSubmitRef.current = false @@ -1255,7 +1324,6 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) return info?.name || model.modelID || currentModel }, [currentModel, model, providersData]) - const isMobile = useMobile() const { setShowDialog, hasForSession: hasPermissionsForSession } = usePermissions() const hasPendingPermissionForSession = hasPermissionsForSession(sessionID) const { hasVariants, currentVariant, cycleVariant } = useVariants(directory, modelSelectionSession) @@ -1354,34 +1422,25 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) : 'Goal mode: the next message becomes the objective' const isGoalModeDisabled = lockedReason !== null || hasOpenGoal || isBashMode || startGoal.isPending const toggleGoalArmed = () => setIsGoalArmed((value) => !value) - const voiceFeedbackState: VoiceStatusOverlayState | null = isTogglingRecording + const voiceFeedbackState: VoiceRecorderStripState | null = isTogglingRecording ? 'starting' : isProcessing ? isVoiceAutoSendPending ? 'sending' : 'processing' : isRecording - ? isVoiceSwipeArmed - ? 'readyToSend' - : 'recording' + ? isVoiceCancelArmed + ? 'cancelArmed' + : isVoiceSendArmed + ? 'readyToSend' + : 'recording' : isVoiceAutoSendPending ? 'sending' : null - const showVoiceFeedback = voiceFeedbackState !== null + const mobileVoiceRecorderState = isMobile ? voiceFeedbackState : null const hasPromptContent = prompt.trim().length > 0 || imageAttachments.length > 0 const canUseMobileVoice = isMobile && sttEnabled && sttSupported && !hasPendingPermissionForSession - const showMobileKeys = isMobile && isPromptFocused && !hasPendingPermissionForSession - const voiceFeedbackLabel = voiceFeedbackState === 'starting' - ? 'Starting microphone...' - : voiceFeedbackState === 'sending' - ? 'Transcribing and sending...' - : voiceFeedbackState === 'processing' - ? 'Transcribing...' - : voiceFeedbackState === 'readyToSend' - ? 'Release to send' - : voiceFeedbackState === 'recording' - ? 'Recording... swipe up to send' - : null + const showMobileKeys = isMobile && isPromptFocused && !hasPendingPermissionForSession && mobileVoiceRecorderState === null const voiceButtonTitle = voiceFeedbackState === 'starting' ? 'Starting microphone' : voiceFeedbackState === 'sending' @@ -1390,9 +1449,22 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) ? 'Transcribing speech' : voiceFeedbackState === 'readyToSend' ? 'Release to send' - : voiceFeedbackState === 'recording' - ? 'Tap to transcribe' - : 'Tap to speak' + : voiceFeedbackState === 'cancelArmed' + ? 'Release to discard' + : voiceFeedbackState === 'recording' + ? 'Tap to transcribe' + : 'Tap to speak' + + const handleSendVoiceRecording = () => { + if (isRecording) { + handleVoiceSendRequest() + return + } + + if (isProcessing && !isVoiceAutoSendPending) { + armVoiceAutoSend() + } + } @@ -1413,7 +1485,7 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) ? 'bg-destructive hover:bg-destructive/90 text-destructive-foreground border-destructive/60 animate-pulse' : 'bg-muted hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground border-border' }` - : `min-h-10 min-w-10 px-0 rounded-lg transition-all duration-150 flex items-center justify-center border touch-none select-none ${ + : `min-h-11 min-w-11 px-0 rounded-lg transition-all duration-150 flex items-center justify-center border touch-none select-none ${ isBusy ? 'bg-success text-success-foreground border-success/70 shadow-lg shadow-success/40' : 'bg-muted hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground border-border active:bg-muted-foreground/30 active:scale-95' @@ -1427,10 +1499,6 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) className={containerClassName} {...voiceGestureHandlers} > - {!isDesktop && showVoiceFeedback && ( -