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
2 changes: 1 addition & 1 deletion frontend/src/components/agent/AgentQuickSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function AgentQuickSelect({
const buttonContent = (
<button
style={styleVars as React.CSSProperties}
className="px-2 md:px-3.5 py-1 h-10 md:h-9 rounded-lg text-sm font-medium border min-w-[56px] max-w-[80px] md:max-w-[100px] flex-shrink-0 flex items-center justify-center transition-all duration-200 active:scale-95 hover:scale-105 shadow-md text-[var(--agent-color)] bg-[var(--agent-bg)] border-[var(--agent-border)] hover:bg-[var(--agent-bg-hover)] hover:border-[var(--agent-border-hover)] shadow-[var(--agent-shadow)] hover:shadow-[var(--agent-shadow-hover)]"
className="px-2 md:px-3.5 py-1 h-11 md:h-9 rounded-lg text-sm font-medium border min-w-[56px] max-w-[80px] md:max-w-[100px] flex-shrink-0 flex items-center justify-center transition-all duration-200 active:scale-95 hover:scale-105 shadow-md text-[var(--agent-color)] bg-[var(--agent-bg)] border-[var(--agent-border)] hover:bg-[var(--agent-bg-hover)] hover:border-[var(--agent-border-hover)] shadow-[var(--agent-shadow)] hover:shadow-[var(--agent-shadow-hover)]"
>
<span className="truncate">{displayName}</span>
</button>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/message/ComposerToolsMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/message/ComposerToolsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')}
>
<Plus className="w-5 h-5" />
</button>
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/message/PromptInput.composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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]')
})

Expand Down
190 changes: 188 additions & 2 deletions frontend/src/components/message/PromptInput.stt.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -588,4 +588,190 @@ describe('PromptInput STT Gesture Tests', () => {
expect(queryMobileVoiceButton()).not.toBeNull()
})
})

describe('in-row mobile voice recorder', () => {
const renderRecorder = (sttOverrides: Partial<MockSTTReturn> = {}) => {
const queryClient = createTestQueryClient()
const buildTree = (overrides: Partial<MockSTTReturn>) => {
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 (
<QueryClientProvider client={queryClient}>
<PromptInput {...defaultProps} onPromptChange={(hasContent) => defaultProps.onPromptChange(hasContent)} />
</QueryClientProvider>
)
}

const view = render(buildTree(sttOverrides))
return { rerender: (overrides: Partial<MockSTTReturn>) => 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()
})
})
})
Loading