From 5d8b2c94459bae3d5bfe0889b9d31b1889f7273f Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:18:16 -0400 Subject: [PATCH 1/5] feat(frontend): hands-free voice conversation mode in the composer The mic opens a voice loop that listens, sends on end of speech, reads each completed reply and reopens the mic when the agent is idle. The phase reducer, pause options and voice-mode instruction move to the shared package. TTS gains per-message completion and pause/resume. The old tap-and-drag recorder, its strip and its CSS are removed. --- frontend/src/api/opencode.ts | 57 + .../message/PromptInput.command.test.tsx | 13 +- .../message/PromptInput.composer.test.tsx | 29 +- .../message/PromptInput.goal.test.tsx | 13 +- .../message/PromptInput.mention.test.tsx | 13 +- .../message/PromptInput.stt.test.tsx | 830 +++++---------- .../src/components/message/PromptInput.tsx | 611 ++--------- .../message/VoiceConversationBar.test.tsx | 201 ++++ .../message/VoiceConversationBar.tsx | 138 +++ .../message/VoiceConversationOrb.tsx | 119 +++ .../components/message/VoiceRecorderStrip.tsx | 104 -- .../components/settings/STTSettings.test.tsx | 97 ++ .../src/components/settings/STTSettings.tsx | 24 + .../components/settings/TTSSettings.test.tsx | 3 + frontend/src/contexts/TTSContext.test.tsx | 230 ++++ frontend/src/contexts/TTSContext.tsx | 125 ++- frontend/src/contexts/tts-context.ts | 11 +- .../useAutoPlayLastResponse.test.tsx | 25 + frontend/src/hooks/useAutoPlayLastResponse.ts | 6 +- frontend/src/hooks/useSTT.test.tsx | 153 +++ frontend/src/hooks/useSTT.ts | 75 +- .../src/hooks/useVoiceConversation.test.tsx | 377 +++++++ frontend/src/hooks/useVoiceConversation.ts | 980 ++++++++++++++++++ .../src/hooks/useVoicePauseSetting.test.ts | 96 ++ frontend/src/hooks/useVoicePauseSetting.ts | 64 ++ frontend/src/index.css | 20 +- frontend/src/lib/voiceConversation.test.ts | 786 ++++++++++++++ frontend/src/lib/webSpeechRecognizer.test.ts | 151 +++ frontend/src/lib/webSpeechRecognizer.ts | 108 +- frontend/src/pages/SessionDetail.tsx | 8 +- frontend/src/test/test-utils.tsx | 27 + shared/src/opencode/index.ts | 36 + shared/src/opencode/voiceConversation.ts | 407 ++++++++ shared/src/opencode/voicePauseSetting.ts | 65 ++ 34 files changed, 4788 insertions(+), 1214 deletions(-) create mode 100644 frontend/src/components/message/VoiceConversationBar.test.tsx create mode 100644 frontend/src/components/message/VoiceConversationBar.tsx create mode 100644 frontend/src/components/message/VoiceConversationOrb.tsx delete mode 100644 frontend/src/components/message/VoiceRecorderStrip.tsx create mode 100644 frontend/src/components/settings/STTSettings.test.tsx create mode 100644 frontend/src/hooks/useVoiceConversation.test.tsx create mode 100644 frontend/src/hooks/useVoiceConversation.ts create mode 100644 frontend/src/hooks/useVoicePauseSetting.test.ts create mode 100644 frontend/src/hooks/useVoicePauseSetting.ts create mode 100644 frontend/src/lib/voiceConversation.test.ts create mode 100644 shared/src/opencode/voiceConversation.ts create mode 100644 shared/src/opencode/voicePauseSetting.ts diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index e8ba22f22..b38aa8afb 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -273,6 +273,63 @@ export async function backgroundSession(sessionID: string, serverId?: string): P await callOpenCode((api) => api.session.background({ sessionID }), serverId) } +/** + * Stores one instruction entry on a session, which the model is told about at + * the next step boundary. + * + * @param sessionID The session that receives the instruction. + * @param key Stable key of the instruction entry. + * @param text Instruction text stored under the key. + * @param serverId Server that owns the session, when it is not the local one. + */ +export async function putSessionInstruction( + sessionID: string, + key: string, + text: string, + serverId?: string, +): Promise { + await callOpenCode( + (api) => api.session.instructions.entry.put({ sessionID, key, value: text }), + serverId, + ) +} + +/** + * Removes one instruction entry from a session. + * + * @param sessionID The session that holds the instruction. + * @param key Stable key of the instruction entry. + * @param serverId Server that owns the session, when it is not the local one. + */ +export async function removeSessionInstruction( + sessionID: string, + key: string, + serverId?: string, +): Promise { + await callOpenCode( + (api) => api.session.instructions.entry.remove({ sessionID, key }), + serverId, + ) +} + +/** + * Lists the keys of the instruction entries a session holds. + * + * @param sessionID The session whose instruction keys are read. + * @param serverId Server that owns the session, when it is not the local one. + * @returns The stored instruction keys, in the order the server reports them. + */ +export async function listSessionInstructionKeys( + sessionID: string, + serverId?: string, +): Promise { + const entries = await callOpenCode( + (api) => api.session.instructions.entry.list({ sessionID }), + serverId, + ) + return entries.map((entry) => entry.key) +} + export async function listShells(directory: string, serverId?: string): Promise { const { data } = await callOpenCode((api) => api.shell.list(openCodeLocation(directory)), serverId) return data diff --git a/frontend/src/components/message/PromptInput.command.test.tsx b/frontend/src/components/message/PromptInput.command.test.tsx index dfc560861..3c6ab98da 100644 --- a/frontend/src/components/message/PromptInput.command.test.tsx +++ b/frontend/src/components/message/PromptInput.command.test.tsx @@ -4,7 +4,7 @@ import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput, type PromptInputHandle } from './PromptInput' import { BUILTIN_COMMANDS } from '@/lib/builtinCommands' -import { createCommandActionsMock, stubMatchMedia } from '@/test/test-utils' +import { createCommandActionsMock, stubMatchMedia, createVoiceConversationControllerMock } from '@/test/test-utils' const mocks = vi.hoisted(() => ({ runCommand: vi.fn(), @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({ dismiss: vi.fn(), }, useSTT: vi.fn(), + useVoiceConversation: vi.fn(), useMobile: vi.fn(), useCommands: vi.fn(), useFileSearch: vi.fn(), @@ -57,7 +58,11 @@ vi.mock('@/hooks/useOpenCode', async () => { } }) -vi.mock('@/hooks/useSTT', () => ({ useSTT: mocks.useSTT })) +vi.mock('@/hooks/useSTT', () => ({ + useSTT: mocks.useSTT, + useSttAvailability: () => ({ isEnabled: true, isSupported: true }), +})) +vi.mock('@/hooks/useVoiceConversation', () => ({ useVoiceConversation: mocks.useVoiceConversation })) vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile })) vi.mock('@/hooks/useCommands', () => ({ useCommands: mocks.useCommands })) vi.mock('@/hooks/useFileSearch', () => ({ useFileSearch: mocks.useFileSearch })) @@ -142,6 +147,9 @@ describe('PromptInput command submission', () => { const defaultProps = { directory: '/test', sessionID: 'test-session', + messages: [], + pending: [], + transcriptStatus: 'idle' as const, showScrollButton: false, isSessionActive: false, isStreamingResponse: false, @@ -169,6 +177,7 @@ describe('PromptInput command submission', () => { beforeEach(() => { vi.clearAllMocks() + mocks.useVoiceConversation.mockReturnValue(createVoiceConversationControllerMock()) mocks.runCommand.mockResolvedValue(undefined) mocks.switchSessionModel.mockResolvedValue(undefined) mocks.switchSessionAgent.mockResolvedValue(undefined) diff --git a/frontend/src/components/message/PromptInput.composer.test.tsx b/frontend/src/components/message/PromptInput.composer.test.tsx index 63474b893..c44ed1e1c 100644 --- a/frontend/src/components/message/PromptInput.composer.test.tsx +++ b/frontend/src/components/message/PromptInput.composer.test.tsx @@ -3,10 +3,11 @@ import { render, screen, fireEvent, waitFor, within, act } from '@testing-librar import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput } from './PromptInput' import { useSessionStatus } from '@/stores/sessionStatusStore' -import { createCommandActionsMock } from '@/test/test-utils' +import { createCommandActionsMock, createVoiceConversationControllerMock } from '@/test/test-utils' const mocks = vi.hoisted(() => ({ useSTT: vi.fn(), + useVoiceConversation: vi.fn(), useMobile: vi.fn(), useCommands: vi.fn(), useCommandHandler: vi.fn(), @@ -23,7 +24,11 @@ const mocks = vi.hoisted(() => ({ setShowDialog: vi.fn(), })) -vi.mock('@/hooks/useSTT', () => ({ useSTT: mocks.useSTT })) +vi.mock('@/hooks/useSTT', () => ({ + useSTT: mocks.useSTT, + useSttAvailability: () => ({ isEnabled: true, isSupported: true }), +})) +vi.mock('@/hooks/useVoiceConversation', () => ({ useVoiceConversation: mocks.useVoiceConversation })) vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile })) vi.mock('@/hooks/useCommands', () => ({ useCommands: mocks.useCommands })) vi.mock('@/hooks/useCommandHandler', () => ({ useCommandHandler: mocks.useCommandHandler })) @@ -88,7 +93,10 @@ const createTestQueryClient = () => new QueryClient({ const defaultProps = { directory: '/test', sessionID: 'test-session', - showScrollButton: false, + messages: [], + pending: [], + transcriptStatus: 'idle' as const, + showScrollButton: false, isSessionActive: false, isStreamingResponse: false, onScrollToBottom: vi.fn(), @@ -106,11 +114,12 @@ describe('PromptInput composer layout', () => { const promptInput = () => screen.getByPlaceholderText('Send a message...') const submitButton = () => document.querySelector('[data-submit-prompt]') const controlsRow = () => screen.getByTestId('composer-controls-row') - const mobileVoiceButton = () => document.querySelector('[data-voice-variant="mobile"]') + const voiceMicButton = () => screen.queryByRole('button', { name: 'Start voice conversation' }) const keyButtons = () => screen.queryAllByRole('toolbar', { name: 'Insert' }) beforeEach(() => { vi.clearAllMocks() + mocks.useVoiceConversation.mockReturnValue(createVoiceConversationControllerMock()) mocks.hasPendingPermission.mockReturnValue(false) mocks.useMobile.mockReturnValue(false) mocks.useSTT.mockReturnValue({ @@ -328,7 +337,7 @@ describe('PromptInput composer layout', () => { fireEvent.focus(promptInput()) const actions = controlsRow().lastElementChild as HTMLElement - expect(actions).toContainElement(mobileVoiceButton() as HTMLElement) + expect(actions).toContainElement(voiceMicButton() as HTMLElement) expect(actions).toContainElement(screen.getByTestId('composer-tools-menu')) }) }) @@ -362,15 +371,15 @@ describe('PromptInput composer layout', () => { fireEvent.change(promptInput(), { target: { value: 'hello' } }) expect(submitButton()).not.toBeNull() - expect(mobileVoiceButton()).not.toBeNull() - expect(controlsRow().lastElementChild).toContainElement(mobileVoiceButton() as HTMLElement) + expect(voiceMicButton()).not.toBeNull() + expect(controlsRow().lastElementChild).toContainElement(voiceMicButton() as HTMLElement) }) it('hides the mic while a permission is pending', () => { mocks.hasPendingPermission.mockReturnValue(true) renderComponent() - expect(mobileVoiceButton()).toBeNull() + expect(voiceMicButton()).toBeNull() expect(screen.getByTitle('View pending permission')).toBeInTheDocument() }) }) @@ -380,13 +389,13 @@ describe('PromptInput composer layout', () => { mocks.useMobile.mockReturnValue(true) renderComponent() - const micBeforeFocus = mobileVoiceButton() as HTMLElement + const micBeforeFocus = voiceMicButton() as HTMLElement expect(micBeforeFocus).toHaveClass('min-h-11', 'min-w-11') expect(micBeforeFocus).not.toHaveClass('min-h-[44px]') fireEvent.focus(promptInput()) - const micAfterFocus = mobileVoiceButton() as HTMLElement + const micAfterFocus = voiceMicButton() as HTMLElement expect(micAfterFocus).toHaveClass('min-h-11', 'min-w-11') expect(micAfterFocus).not.toHaveClass('min-h-[44px]') }) diff --git a/frontend/src/components/message/PromptInput.goal.test.tsx b/frontend/src/components/message/PromptInput.goal.test.tsx index 55fdb8e1d..addbb86dd 100644 --- a/frontend/src/components/message/PromptInput.goal.test.tsx +++ b/frontend/src/components/message/PromptInput.goal.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput } from './PromptInput' -import { createCommandActionsMock, stubMatchMedia } from '@/test/test-utils' +import { createCommandActionsMock, stubMatchMedia, createVoiceConversationControllerMock } from '@/test/test-utils' const mocks = vi.hoisted(() => ({ sendPrompt: vi.fn(), @@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({ dismiss: vi.fn(), }, useSTT: vi.fn(), + useVoiceConversation: vi.fn(), useMobile: vi.fn(), useCommands: vi.fn(), useFileSearch: vi.fn(), @@ -73,7 +74,11 @@ vi.mock('@/hooks/useSessionPermissionMode', () => ({ useSessionPermissionMode: mocks.useSessionPermissionMode, })) -vi.mock('@/hooks/useSTT', () => ({ useSTT: mocks.useSTT })) +vi.mock('@/hooks/useSTT', () => ({ + useSTT: mocks.useSTT, + useSttAvailability: () => ({ isEnabled: true, isSupported: true }), +})) +vi.mock('@/hooks/useVoiceConversation', () => ({ useVoiceConversation: mocks.useVoiceConversation })) vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile })) vi.mock('@/hooks/useCommands', () => ({ useCommands: mocks.useCommands })) vi.mock('@/hooks/useFileSearch', () => ({ useFileSearch: mocks.useFileSearch })) @@ -140,6 +145,9 @@ describe('PromptInput goal mode', () => { const defaultProps = { directory: '/test', sessionID: 'test-session', + messages: [], + pending: [], + transcriptStatus: 'idle' as const, showScrollButton: false, isSessionActive: false, isStreamingResponse: false, @@ -159,6 +167,7 @@ describe('PromptInput goal mode', () => { beforeEach(() => { vi.clearAllMocks() + mocks.useVoiceConversation.mockReturnValue(createVoiceConversationControllerMock()) mocks.useSessionGoal.mockReturnValue({ data: null }) mocks.useSessionPermissionMode.mockReturnValue({ data: undefined }) mocks.startGoal.mockResolvedValue({ id: 1 }) diff --git a/frontend/src/components/message/PromptInput.mention.test.tsx b/frontend/src/components/message/PromptInput.mention.test.tsx index cfcc9bdd2..d243ff902 100644 --- a/frontend/src/components/message/PromptInput.mention.test.tsx +++ b/frontend/src/components/message/PromptInput.mention.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput } from './PromptInput' -import { createCommandActionsMock } from '@/test/test-utils' +import { createCommandActionsMock, createVoiceConversationControllerMock } from '@/test/test-utils' const mocks = vi.hoisted(() => ({ sendPrompt: vi.fn(), @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ agents: [] as Array<{ name: string; description?: string }>, skills: [] as Array<{ id: string; name: string; description?: string }>, useSTT: vi.fn(), + useVoiceConversation: vi.fn(), useMobile: vi.fn(), useCommands: vi.fn(), useFileSearch: vi.fn(), @@ -45,7 +46,11 @@ vi.mock('@/hooks/useOpenCode', async () => { } }) -vi.mock('@/hooks/useSTT', () => ({ useSTT: mocks.useSTT })) +vi.mock('@/hooks/useSTT', () => ({ + useSTT: mocks.useSTT, + useSttAvailability: () => ({ isEnabled: true, isSupported: true }), +})) +vi.mock('@/hooks/useVoiceConversation', () => ({ useVoiceConversation: mocks.useVoiceConversation })) vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile })) vi.mock('@/hooks/useCommands', () => ({ useCommands: mocks.useCommands })) vi.mock('@/hooks/useFileSearch', () => ({ useFileSearch: mocks.useFileSearch })) @@ -128,6 +133,9 @@ describe('PromptInput agent mention submission', () => { const defaultProps = { directory: '/test', sessionID: 'test-session', + messages: [], + pending: [], + transcriptStatus: 'idle' as const, showScrollButton: false, isSessionActive: false, isStreamingResponse: false, @@ -148,6 +156,7 @@ describe('PromptInput agent mention submission', () => { beforeEach(() => { vi.clearAllMocks() + mocks.useVoiceConversation.mockReturnValue(createVoiceConversationControllerMock()) mocks.sendPrompt.mockResolvedValue({ id: 'inbox_1', sessionID: 'test-session', diff --git a/frontend/src/components/message/PromptInput.stt.test.tsx b/frontend/src/components/message/PromptInput.stt.test.tsx index 00c1c63dc..537797e23 100644 --- a/frontend/src/components/message/PromptInput.stt.test.tsx +++ b/frontend/src/components/message/PromptInput.stt.test.tsx @@ -1,8 +1,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, act, waitFor, within } from '@testing-library/react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput } from './PromptInput' -import { createCommandActionsMock } from '@/test/test-utils' +import { + createCommandActionsMock, + createVoiceConversationControllerMock, +} from '@/test/test-utils' +import type { + UseVoiceConversationInput, + VoiceConversationController, +} from '@/hooks/useVoiceConversation' const createTestQueryClient = () => new QueryClient({ defaultOptions: { @@ -13,27 +20,23 @@ const createTestQueryClient = () => new QueryClient({ }) const mocks = vi.hoisted(() => ({ - useSTT: vi.fn(), + useSttAvailability: vi.fn(), + useVoiceConversation: vi.fn(), useMobile: vi.fn(), - useOpenCode: vi.fn(), - useCommands: vi.fn(), - useCommandHandler: vi.fn(), - useFileSearch: vi.fn(), - useModelSelection: vi.fn(), - useVariants: vi.fn(), - useSessionAgent: vi.fn(), - useAgents: vi.fn(), useSendPromptMutate: vi.fn(), + useSendPromptMutateAsync: vi.fn(), sendPromptPending: vi.fn(() => false), useUserBash: vi.fn(), useSessionAgentStore: vi.fn(), useSendErrorStore: vi.fn(), - useSettings: vi.fn(), - EventContext: vi.fn(), })) vi.mock('@/hooks/useSTT', () => ({ - useSTT: mocks.useSTT, + useSttAvailability: mocks.useSttAvailability, +})) + +vi.mock('@/hooks/useVoiceConversation', () => ({ + useVoiceConversation: mocks.useVoiceConversation, })) vi.mock('@/hooks/useMobile', () => ({ @@ -41,35 +44,48 @@ vi.mock('@/hooks/useMobile', () => ({ })) vi.mock('@/hooks/useOpenCode', () => ({ - useSendPrompt: () => ({ mutate: mocks.useSendPromptMutate, isPending: mocks.sendPromptPending() }), - useInterruptSession: () => ({ mutate: vi.fn() }), + useSendPrompt: () => ({ + mutate: mocks.useSendPromptMutate, + mutateAsync: mocks.useSendPromptMutateAsync, + isPending: mocks.sendPromptPending(), + }), + useInterruptSession: () => ({ mutate: vi.fn(), mutateAsync: vi.fn() }), useSendShell: () => ({ mutate: vi.fn(), isPending: false }), useAgents: () => ({ data: [] }), useSkills: () => ({ data: [] }), })) vi.mock('@/hooks/useCommands', () => ({ - useCommands: mocks.useCommands, + useCommands: () => ({ searchCommands: () => [], findCommand: vi.fn(), recentNames: [] }), })) vi.mock('@/hooks/useCommandHandler', () => ({ - useCommandHandler: mocks.useCommandHandler, + useCommandHandler: () => ({ executeCommand: vi.fn() }), })) vi.mock('@/hooks/useFileSearch', () => ({ - useFileSearch: mocks.useFileSearch, + useFileSearch: () => ({ files: [] }), })) vi.mock('@/hooks/useModelSelection', () => ({ - useModelSelection: mocks.useModelSelection, + useModelSelection: () => ({ + model: null, + modelString: 'test-model', + setModel: vi.fn(), + setActiveAgent: vi.fn(), + recentModels: [], + favoriteModels: [], + toggleFavorite: vi.fn(), + isModelReady: true, + }), })) vi.mock('@/hooks/useVariants', () => ({ - useVariants: mocks.useVariants, + useVariants: () => ({ hasVariants: false, currentVariant: null, cycleVariant: vi.fn() }), })) vi.mock('@/hooks/useSessionAgent', () => ({ - useSessionAgent: mocks.useSessionAgent, + useSessionAgent: () => ({ agent: 'default' }), })) vi.mock('@/stores/userBashStore', () => ({ @@ -89,7 +105,6 @@ vi.mock('@/contexts/EventContext', () => ({ hasForSession: vi.fn().mockReturnValue(false), setShowDialog: vi.fn(), }), - EventContext: mocks.EventContext, })) vi.mock('@/components/agent/AgentQuickSelect', () => ({ @@ -125,32 +140,15 @@ vi.mock('./PromptSuggestions', () => ({ PromptSuggestions: () => null, })) -interface MockSTTReturn { - isRecording: boolean - isProcessing: boolean - isSupported: boolean - isEnabled: boolean - interimTranscript: string - transcript: string - startRecording: ReturnType - stopRecording: ReturnType - abortRecording: ReturnType - reset: ReturnType - clear: ReturnType -} - -describe('PromptInput STT Gesture Tests', () => { - const mockStartRecording = vi.fn() - const mockStopRecording = vi.fn() - const mockAbortRecording = vi.fn() - const mockReset = vi.fn() - const mockClear = vi.fn() +describe('PromptInput voice conversation', () => { const mockSetAgent = vi.fn() const defaultProps = { directory: '/test', sessionID: 'test-session', - repoId: 1, + messages: [], + pending: [], + transcriptStatus: 'idle' as const, showScrollButton: false, isSessionActive: false, isStreamingResponse: false, @@ -159,619 +157,351 @@ describe('PromptInput STT Gesture Tests', () => { onPromptChange: vi.fn(), } - beforeEach(() => { - vi.clearAllMocks() - mockStartRecording.mockResolvedValue(true) - mockStopRecording.mockReturnValue(undefined) - mockAbortRecording.mockReturnValue(undefined) - mockReset.mockReturnValue(undefined) - mockClear.mockReturnValue(undefined) - mockSetAgent.mockClear() - mocks.useSendPromptMutate.mockImplementation((_variables, options) => { - options?.onSuccess?.() - }) - mocks.sendPromptPending.mockReturnValue(false) - - mocks.useMobile.mockReturnValue(true) - mocks.useSTT.mockReturnValue({ - isRecording: false, - isProcessing: false, - isSupported: true, - isEnabled: true, - interimTranscript: '', - transcript: '', - startRecording: mockStartRecording, - stopRecording: mockStopRecording, - abortRecording: mockAbortRecording, - reset: mockReset, - clear: mockClear, - } as unknown as MockSTTReturn) - - mocks.useCommands.mockReturnValue({ searchCommands: () => [], findCommand: vi.fn(), recentNames: [] }) - mocks.useCommandHandler.mockReturnValue({ executeCommand: vi.fn() }) - mocks.useFileSearch.mockReturnValue({ files: [] }) - mocks.useModelSelection.mockReturnValue({ - model: null, - modelString: 'test-model', - setModel: vi.fn(), - setActiveAgent: vi.fn(), - recentModels: [], - favoriteModels: [], - toggleFavorite: vi.fn(), - isModelReady: true, - }) - mocks.useVariants.mockReturnValue({ - hasVariants: false, - currentVariant: null, - cycleVariant: vi.fn(), - }) - mocks.useSessionAgent.mockReturnValue({ agent: 'default' }) - mocks.useAgents.mockReturnValue({ data: [] }) - mocks.useUserBash.mockImplementation((selector) => selector({ addUserBashCommand: vi.fn() })) - mocks.useSessionAgentStore.mockImplementation((selector) => selector({ setAgent: mockSetAgent })) - mocks.useSendErrorStore.mockImplementation((selector) => selector({ errors: {} })) - }) - - const renderComponent = (sttOverrides: Partial = {}) => { + const renderComponent = (overrides: Partial = {}) => { const queryClient = createTestQueryClient() - mocks.useSTT.mockReturnValue({ - isRecording: false, - isProcessing: false, - isSupported: true, - isEnabled: true, - interimTranscript: '', - transcript: '', - startRecording: mockStartRecording, - stopRecording: mockStopRecording, - abortRecording: mockAbortRecording, - reset: mockReset, - clear: mockClear, - ...sttOverrides, - } as unknown as MockSTTReturn) - return render( + const tree = (nextOverrides: Partial) => ( - + ) - } - - const getMobileVoiceButton = () => { - const allButtons = screen.getAllByRole('button') - const voiceButtons = allButtons.filter((btn) => { - const title = (btn.getAttribute('title') || '').toLowerCase() - return title.includes('tap to speak') || title.includes('tap to transcribe') || title.includes('hold to speak') || title.includes('release') - }) - if (voiceButtons.length === 0) { - throw new Error('No voice button found. Available buttons: ' + allButtons.map(b => b.getAttribute('title')).join(', ')) + const view = render(tree({})) + return { + ...view, + rerenderWith: (nextOverrides: Partial) => view.rerender(tree(nextOverrides)), } - const mobileButton = voiceButtons.find((btn) => btn.getAttribute('data-voice-variant') === 'mobile') - return mobileButton || voiceButtons[0] } - const getMobileVoiceButtonContainer = () => { - const mobileButton = getMobileVoiceButton() - const container = mobileButton.parentElement - if (!container) { - throw new Error('Mobile voice button container not found') - } - return container + const setVoiceController = ( + controller: Partial = {}, + capturedText = 'half a sentence', + ) => { + mocks.useVoiceConversation.mockImplementation((input: UseVoiceConversationInput) => + createVoiceConversationControllerMock({ + ...controller, + editInTextBox: () => input.onEditInTextBox(capturedText), + }), + ) } - describe('quick tap behavior', () => { - it('keeps submitted text until send succeeds', async () => { - mocks.useSendPromptMutate.mockImplementation(() => undefined) - renderComponent() - - const input = screen.getByPlaceholderText('Send a message...') - fireEvent.change(input, { target: { value: 'retry me' } }) - fireEvent.click(screen.getByTitle('Send')) - - expect(input).toHaveValue('retry me') - }) - - it('clears submitted text after send success', async () => { - renderComponent() + const voiceInput = (): UseVoiceConversationInput => { + const call = mocks.useVoiceConversation.mock.calls.at(-1) + if (!call) throw new Error('useVoiceConversation was not called') + return call[0] as UseVoiceConversationInput + } - const input = screen.getByPlaceholderText('Send a message...') - fireEvent.change(input, { target: { value: 'sent message' } }) - fireEvent.click(screen.getByTitle('Send')) + const promptInput = () => screen.getByPlaceholderText('Send a message...') + const voiceMic = () => screen.queryByRole('button', { name: 'Start voice conversation' }) - await waitFor(() => { - expect(input).toHaveValue('') - }) - }) - - it('clears submitted text once the server confirms it is processing', async () => { - mocks.useSendPromptMutate.mockImplementation(() => undefined) - const queryClient = createTestQueryClient() - const { rerender } = render( - - - , - ) - - const input = screen.getByPlaceholderText('Send a message...') - fireEvent.change(input, { target: { value: 'do the thing' } }) - fireEvent.click(screen.getByTitle('Send')) - - expect(input).toHaveValue('do the thing') - - rerender( - - - , - ) - - await waitFor(() => { - expect(input).toHaveValue('') - }) + beforeEach(() => { + vi.clearAllMocks() + mocks.useSendPromptMutate.mockImplementation((_variables, options) => { + options?.onSuccess?.() }) + mocks.useSendPromptMutateAsync.mockResolvedValue({ id: 'inbox_1' }) + mocks.sendPromptPending.mockReturnValue(false) + mocks.useMobile.mockReturnValue(true) + mocks.useSttAvailability.mockReturnValue({ isEnabled: true, isSupported: true }) + setVoiceController() + mocks.useUserBash.mockImplementation((selector) => selector({ addUserBashCommand: vi.fn() })) + mocks.useSessionAgentStore.mockImplementation((selector) => selector({ setAgent: mockSetAgent })) + mocks.useSendErrorStore.mockImplementation((selector) => selector({ errors: {} })) + }) - it('allows queuing a follow-up while a non-queued send is pending', async () => { - mocks.sendPromptPending.mockReturnValue(true) - render( - - - , - ) - - const input = screen.getByPlaceholderText('Send a message...') - fireEvent.change(input, { target: { value: 'follow-up' } }) - - const queueButton = screen.getByTitle('Queue message') - expect(queueButton).not.toBeDisabled() + describe('microphone availability', () => { + it('shows the microphone on mobile when speech-to-text is enabled and supported', () => { + mocks.useMobile.mockReturnValue(true) - fireEvent.click(queueButton) + renderComponent() - const [callArgs] = mocks.useSendPromptMutate.mock.calls[0] as [{ text: string }, unknown] - expect(callArgs.text).toBe('follow-up') + expect(voiceMic()).toBeInTheDocument() }) - it('restores a failed queued prompt when the input is empty', async () => { - const queryClient = createTestQueryClient() - const { rerender } = render( - - - , - ) - - const input = screen.getByPlaceholderText('Send a message...') - fireEvent.change(input, { target: { value: 'queued message' } }) - fireEvent.click(screen.getByTitle('Queue message')) - - const [callArgs] = mocks.useSendPromptMutate.mock.calls[0] as [{ text: string }, unknown] - expect(callArgs.text).toBe('queued message') - - await waitFor(() => { - expect(input).toHaveValue('') - }) - - mocks.useSendErrorStore.mockImplementation((selector) => selector({ - errors: { - 'test-session': { - sessionID: 'test-session', - title: 'Error', - message: 'Queued send failed', - failedPrompt: 'queued message', - }, - }, - })) + it('shows the microphone on desktop when speech-to-text is enabled and supported', () => { + mocks.useMobile.mockReturnValue(false) - rerender( - - - , - ) + renderComponent() - await waitFor(() => { - expect(screen.getByPlaceholderText('Send a message...')).toHaveValue('queued message') - }) + expect(voiceMic()).toBeInTheDocument() }) - it('clears the restored prompt when the send error resolves', async () => { - const queryClient = createTestQueryClient() - mocks.useSendErrorStore.mockImplementation((selector) => selector({ - errors: { - 'test-session': { - sessionID: 'test-session', - title: 'Connection Failed', - message: 'Could not connect.', - failedPrompt: 'recovered prompt', - kind: 'network', - }, - }, - })) - - const { rerender } = render( - - - , - ) - - await waitFor(() => { - expect(screen.getByPlaceholderText('Send a message...')).toHaveValue('recovered prompt') - }) + it('hides the microphone when speech-to-text is disabled', () => { + mocks.useSttAvailability.mockReturnValue({ isEnabled: false, isSupported: true }) - mocks.useSendErrorStore.mockImplementation((selector) => selector({ errors: {} })) - - rerender( - - - , - ) + renderComponent() - await waitFor(() => { - expect(screen.getByPlaceholderText('Send a message...')).toHaveValue('') - }) + expect(voiceMic()).toBeNull() }) - it('keeps stop available while active with prompt content', async () => { - render( - - - - ) + it('hides the microphone when the browser cannot capture speech', () => { + mocks.useSttAvailability.mockReturnValue({ isEnabled: true, isSupported: false }) - const input = screen.getByPlaceholderText('Send a message...') - fireEvent.change(input, { target: { value: 'draft while active' } }) + renderComponent() - expect(screen.getAllByTitle('Stop').length).toBeGreaterThan(0) + expect(voiceMic()).toBeNull() }) - it('quick tap starts recording through click only', async () => { - mockStartRecording.mockResolvedValue(true) + it('starts the conversation when the microphone is pressed', () => { + const controller = createVoiceConversationControllerMock() + mocks.useVoiceConversation.mockReturnValue(controller) renderComponent() + fireEvent.click(voiceMic() as HTMLElement) - const container = getMobileVoiceButtonContainer() - const button = getMobileVoiceButton() - - await act(async () => { - fireEvent.pointerDown(container) - fireEvent.pointerUp(container) - fireEvent.click(button) - await new Promise(resolve => setTimeout(resolve, 10)) - }) - - await waitFor(() => { - expect(mockStartRecording).toHaveBeenCalledTimes(1) - }) - - expect(mockStopRecording).not.toHaveBeenCalled() + expect(controller.start).toHaveBeenCalledTimes(1) }) + }) - it('quick tap does not start recording on pointerdown alone', async () => { - mockStartRecording.mockResolvedValue(true) + describe('while a conversation is active', () => { + it('replaces the composer with the conversation bar and keeps the draft after exit', () => { + const onVoiceConversationActiveChange = vi.fn() + const view = renderComponent({ onVoiceConversationActiveChange }) - renderComponent() + fireEvent.change(promptInput(), { target: { value: 'keep this draft' } }) - const container = getMobileVoiceButtonContainer() - const button = getMobileVoiceButton() + setVoiceController({ isActive: true, phase: 'listening', label: 'Listening' }) + view.rerenderWith({ messages: [] }) - await act(async () => { - fireEvent.pointerDown(container) - fireEvent.pointerUp(container) - await new Promise(resolve => setTimeout(resolve, 10)) - }) + expect(screen.queryByPlaceholderText('Send a message...')).toBeNull() + expect(screen.getByText('Listening')).toBeInTheDocument() + expect(onVoiceConversationActiveChange).toHaveBeenLastCalledWith(true) - expect(mockStartRecording).not.toHaveBeenCalled() + setVoiceController() + view.rerenderWith({ messages: [] }) - await act(async () => { - fireEvent.click(button) - await new Promise(resolve => setTimeout(resolve, 10)) - }) - - await waitFor(() => { - expect(mockStartRecording).toHaveBeenCalledTimes(1) - }) + expect(promptInput()).toHaveValue('keep this draft') + expect(onVoiceConversationActiveChange).toHaveBeenLastCalledWith(false) }) - it('pointer down while recording starts a drag gesture', async () => { - renderComponent({ isRecording: true }) + it('moves the captured transcript into the text box', () => { + setVoiceController({ isActive: true, phase: 'listening', canEditInTextBox: true }, 'dictated words') - const container = getMobileVoiceButtonContainer() + const view = renderComponent() + fireEvent.click(screen.getByRole('button', { name: 'Edit in text box' })) - await act(async () => { - fireEvent.pointerDown(container) - }) + setVoiceController() + view.rerenderWith({ messages: [] }) - expect(mockStartRecording).not.toHaveBeenCalled() - expect(mockStopRecording).not.toHaveBeenCalled() + expect(promptInput()).toHaveValue('dictated words') }) - it('second tap while recording stops', async () => { - renderComponent({ isRecording: true }) + it('appends the captured transcript after an existing draft', () => { + const view = renderComponent() - const container = getMobileVoiceButtonContainer() - const button = getMobileVoiceButton() - - await act(async () => { - fireEvent.pointerDown(container) - fireEvent.pointerUp(container) - fireEvent.click(button) - await new Promise(resolve => setTimeout(resolve, 10)) - }) + fireEvent.change(promptInput(), { target: { value: 'draft' } }) - await waitFor(() => { - expect(mockStopRecording).toHaveBeenCalledTimes(1) - }) - expect(mockStartRecording).not.toHaveBeenCalled() - }) + setVoiceController({ isActive: true, phase: 'listening', canEditInTextBox: true }, 'dictated words') + view.rerenderWith({ messages: [] }) + fireEvent.click(screen.getByRole('button', { name: 'Edit in text box' })) - it('component sets up outside press handler when recording', async () => { - renderComponent({ isRecording: true }) + setVoiceController() + view.rerenderWith({ messages: [] }) - expect(document.body.onclick).toBeDefined() + expect(promptInput()).toHaveValue('draft dictated words') }) + }) - it('failed start clears toggling state', async () => { - mockStartRecording.mockResolvedValue(false) - - renderComponent() - - const button = getMobileVoiceButton() - - await act(async () => { - fireEvent.pointerDown(button) - fireEvent.pointerUp(button) - fireEvent.click(button) - await new Promise(resolve => setTimeout(resolve, 10)) - }) + describe('sending a voice turn', () => { + it('queues the turn while the session is busy and leaves the draft untouched', async () => { + const view = renderComponent() - await waitFor(() => { - expect(mockStartRecording).toHaveBeenCalledTimes(1) - }) - }) + fireEvent.change(promptInput(), { target: { value: 'my draft' } }) - it('renders mobile mic even when showScrollButton is true and no voice feedback is active', async () => { - mocks.useMobile.mockReturnValue(true) + await voiceInput().sendTurn('add a retry', { busy: true }) - render( - - - - ) - - const allButtons = screen.getAllByRole('button') - const voiceButtons = allButtons.filter((btn) => { - const title = (btn.getAttribute('title') || '').toLowerCase() - return title.includes('tap to speak') || title.includes('tap to transcribe') || title.includes('hold to speak') + expect(mocks.useSendPromptMutateAsync).toHaveBeenCalledWith({ + sessionID: 'test-session', + text: 'add a retry', + model: undefined, + agent: 'default', + delivery: 'queue', }) - - expect(voiceButtons.length).toBeGreaterThan(0) + view.rerenderWith({}) + expect(promptInput()).toHaveValue('my draft') + expect(mockSetAgent).toHaveBeenCalledWith('test-session', 'default') }) - it('renders the mobile scroll-to-bottom button when showScrollButton is true', async () => { - mocks.useMobile.mockReturnValue(true) - - render( - - - - ) + it('sends the turn without a delivery while the session is idle', async () => { + renderComponent() - expect(screen.getByRole('button', { name: 'Scroll to bottom' })).toBeInTheDocument() - expect(screen.queryByText('Latest')).not.toBeInTheDocument() - }) - }) + await voiceInput().sendTurn('add a retry', { busy: false }) - describe('mobile voice vs submit button', () => { - const queryMobileVoiceButton = () => { - const voiceButtons = screen.getAllByRole('button').filter((btn) => { - const title = (btn.getAttribute('title') || '').toLowerCase() - return title.includes('tap to speak') || title.includes('tap to transcribe') || title.includes('hold to speak') || title.includes('release') + expect(mocks.useSendPromptMutateAsync).toHaveBeenCalledWith({ + sessionID: 'test-session', + text: 'add a retry', + model: undefined, + agent: 'default', }) - return voiceButtons.find((btn) => btn.getAttribute('data-voice-variant') === 'mobile') ?? null - } - - it('shows the mobile mic and no submit button when the prompt is empty', () => { - renderComponent() - - expect(queryMobileVoiceButton()).not.toBeNull() - expect(screen.queryByTitle('Send')).not.toBeInTheDocument() }) - it('shows the submit button and keeps the mobile mic once the prompt has content', () => { + it('rejects when the send fails so the conversation can report it', async () => { + mocks.useSendPromptMutateAsync.mockRejectedValue(new Error('offline')) renderComponent() - fireEvent.change(screen.getByPlaceholderText('Send a message...'), { target: { value: 'hello' } }) - - expect(screen.getByTitle('Send')).toBeInTheDocument() - expect(queryMobileVoiceButton()).not.toBeNull() + await expect(voiceInput().sendTurn('add a retry', { busy: false })).rejects.toThrow('offline') }) + }) +}) - it('keeps the mobile mic visible while recording even with prompt content', () => { - renderComponent({ isRecording: true }) +describe('PromptInput submission', () => { + const defaultProps = { + directory: '/test', + sessionID: 'test-session', + messages: [], + pending: [], + transcriptStatus: 'idle' as const, + showScrollButton: false, + isSessionActive: false, + isStreamingResponse: false, + onScrollToBottom: vi.fn(), + commandActions: createCommandActionsMock(), + onPromptChange: vi.fn(), + } - fireEvent.change(screen.getByPlaceholderText('Send a message...'), { target: { value: 'hello' } }) + const renderComponent = (initialOverrides: Partial = {}) => { + const queryClient = createTestQueryClient() + const tree = (overrides: Partial = {}) => ( + + + + ) + const view = render(tree()) + return { ...view, rerenderWith: (overrides: Partial) => view.rerender(tree(overrides)) } + } - expect(queryMobileVoiceButton()).not.toBeNull() + beforeEach(() => { + vi.clearAllMocks() + mocks.useSendPromptMutate.mockImplementation((_variables, options) => { + options?.onSuccess?.() }) + mocks.sendPromptPending.mockReturnValue(false) + mocks.useMobile.mockReturnValue(true) + mocks.useSttAvailability.mockReturnValue({ isEnabled: true, isSupported: true }) + mocks.useVoiceConversation.mockReturnValue(createVoiceConversationControllerMock()) + mocks.useUserBash.mockImplementation((selector) => selector({ addUserBashCommand: vi.fn() })) + mocks.useSessionAgentStore.mockImplementation((selector) => selector({ setAgent: vi.fn() })) + mocks.useSendErrorStore.mockImplementation((selector) => selector({ errors: {} })) }) - 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 }) + it('keeps submitted text until send succeeds', () => { + mocks.useSendPromptMutate.mockImplementation(() => undefined) + renderComponent() - const row = getControlsRow() + const input = screen.getByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: 'retry me' } }) + fireEvent.click(screen.getByTitle('Send')) - 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 ›') - }) + expect(input).toHaveValue('retry me') + }) - it('hides the mobile insert keys and renders nothing above the mic', () => { - renderComponent({ isRecording: true }) + it('clears submitted text after send success', async () => { + renderComponent() - fireEvent.focus(screen.getByPlaceholderText('Send a message...')) + const input = screen.getByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: 'sent message' } }) + fireEvent.click(screen.getByTitle('Send')) - expect(screen.queryByRole('toolbar', { name: 'Insert' })).not.toBeInTheDocument() - expect(getMobileVoiceButtonContainer().children).toHaveLength(1) + await waitFor(() => { + expect(input).toHaveValue('') }) + }) - 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) + it('clears submitted text once the server confirms it is processing', async () => { + mocks.useSendPromptMutate.mockImplementation(() => undefined) + const view = renderComponent() - view.rerender({ isRecording: false, isProcessing: true }) + const input = screen.getByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: 'do the thing' } }) + fireEvent.click(screen.getByTitle('Send')) - expect(screen.getByText('Transcribing & sending…')).toBeInTheDocument() + expect(input).toHaveValue('do the thing') - view.rerender({ isRecording: false, isProcessing: false, transcript: 'hello there' }) + view.rerenderWith({ isStreamingResponse: true }) - await waitFor(() => { - const [variables] = mocks.useSendPromptMutate.mock.calls[0] as [{ text: string }] - expect(variables.text).toBe('hello there') - }) + await waitFor(() => { + expect(input).toHaveValue('') }) + }) - 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() + it('allows queuing a follow-up while a non-queued send is pending', () => { + mocks.sendPromptPending.mockReturnValue(true) + renderComponent({ isStreamingResponse: true }) - fireEvent.click(screen.getByRole('button', { name: 'Send recording' })) + const input = screen.getByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: 'follow-up' } }) - expect(mockStopRecording).not.toHaveBeenCalled() + const queueButton = screen.getByTitle('Queue message') + expect(queueButton).not.toBeDisabled() - view.rerender({ isRecording: false, isProcessing: false, transcript: 'upgraded text' }) + fireEvent.click(queueButton) - await waitFor(() => { - const [variables] = mocks.useSendPromptMutate.mock.calls[0] as [{ text: string }] - expect(variables.text).toBe('upgraded text') - }) - }) + const [callArgs] = mocks.useSendPromptMutate.mock.calls[0] as [{ text: string }, unknown] + expect(callArgs.text).toBe('follow-up') + }) - it('aborts recording when discard is tapped', () => { - renderComponent({ isRecording: true }) - mockAbortRecording.mockClear() + it('restores a failed queued prompt when the input is empty', async () => { + const view = renderComponent({ isStreamingResponse: true }) - const discardButton = screen.getByRole('button', { name: 'Discard recording' }) + const input = screen.getByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: 'queued message' } }) + fireEvent.click(screen.getByTitle('Queue message')) - fireEvent.pointerDown(discardButton) - fireEvent.click(discardButton) + const [callArgs] = mocks.useSendPromptMutate.mock.calls[0] as [{ text: string }, unknown] + expect(callArgs.text).toBe('queued message') - expect(mockAbortRecording).toHaveBeenCalledTimes(1) - expect(mockStopRecording).not.toHaveBeenCalled() + await waitFor(() => { + expect(input).toHaveValue('') }) - 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 }) - }) + mocks.useSendErrorStore.mockImplementation((selector) => selector({ + errors: { + 'test-session': { + sessionID: 'test-session', + title: 'Error', + message: 'Queued send failed', + failedPrompt: 'queued message', + }, + }, + })) - expect(screen.getByText('Release to send')).toBeInTheDocument() - expect(mockStopRecording).not.toHaveBeenCalled() + view.rerenderWith({}) - await act(async () => { - fireEvent.pointerUp(container, { clientX: 130 }) - }) - - expect(mockStopRecording).toHaveBeenCalledTimes(1) + await waitFor(() => { + expect(screen.getByPlaceholderText('Send a message...')).toHaveValue('queued message') }) + }) - 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() + it('clears the restored prompt when the send error resolves', async () => { + mocks.useSendErrorStore.mockImplementation((selector) => selector({ + errors: { + 'test-session': { + sessionID: 'test-session', + title: 'Connection Failed', + message: 'Could not connect.', + failedPrompt: 'recovered prompt', + kind: 'network', + }, + }, + })) - await act(async () => { - fireEvent.pointerUp(container, { clientX: 100 }) - }) + const view = renderComponent() - expect(mockAbortRecording).toHaveBeenCalledTimes(1) - expect(mockStopRecording).not.toHaveBeenCalled() + await waitFor(() => { + expect(screen.getByPlaceholderText('Send a message...')).toHaveValue('recovered prompt') }) - 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)) - }) + mocks.useSendErrorStore.mockImplementation((selector) => selector({ errors: {} })) + view.rerenderWith({}) - expect(mockStartRecording).not.toHaveBeenCalled() - expect(screen.queryByRole('button', { name: 'Discard recording' })).not.toBeInTheDocument() + await waitFor(() => { + expect(screen.getByPlaceholderText('Send a message...')).toHaveValue('') }) + }) - it('leaves the desktop composer without the recorder row', () => { - mocks.useMobile.mockReturnValue(false) + it('keeps stop available while active with prompt content', () => { + renderComponent({ isSessionActive: true }) - renderComponent({ isRecording: true }) + fireEvent.change(screen.getByPlaceholderText('Send a message...'), { target: { value: 'draft while active' } }) - 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() - }) + expect(screen.getAllByTitle('Stop').length).toBeGreaterThan(0) }) }) diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 2af3065b5..9ccf6b5f9 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useMemo, useImperativeHandle, forwardRef, memo, useCallback, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react' +import { useState, useRef, useEffect, useMemo, useImperativeHandle, forwardRef, memo, useCallback, type KeyboardEvent } from 'react' import { useSendPrompt, useInterruptSession, useSendShell, useAgents, useSkills } from '@/hooks/useOpenCode' import { useCommands } from '@/hooks/useCommands' import { useCommandHandler } from '@/hooks/useCommandHandler' @@ -6,7 +6,9 @@ import { useFileSearch } from '@/hooks/useFileSearch' import { useModelSelection } from '@/hooks/useModelSelection' import { useVariants } from '@/hooks/useVariants' import { useSessionAgent } from '@/hooks/useSessionAgent' -import { useSTT } from '@/hooks/useSTT' +import { useSttAvailability } from '@/hooks/useSTT' +import { useVoiceConversation } from '@/hooks/useVoiceConversation' +import { useVoicePauseSetting } from '@/hooks/useVoicePauseSetting' import { useUserBash } from '@/stores/userBashStore' import { useSessionAgentStore } from '@/stores/sessionAgentStore' @@ -19,7 +21,7 @@ import { FINE_POINTER_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' import { usePermissions } from '@/contexts/EventContext' import { isLocalOpenCodeServer, useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' -import { ArrowDown, ArrowUp, Upload, X, Mic, MicOff, Target } from 'lucide-react' +import { ArrowDown, Upload, X, Mic, Target } from 'lucide-react' import { SquareFill } from '@/components/ui/square-fill' import { IconToggleButton, getIconToggleButtonClassName } from '@/components/ui/icon-toggle-button' @@ -28,7 +30,7 @@ import { PromptSuggestions, type PromptSuggestion } from './PromptSuggestions' import { ComposerActivityBar } from './ComposerActivityBar' import { ModelQuickSelect } from '@/components/model/ModelQuickSelect' import { AgentQuickSelect } from '@/components/agent/AgentQuickSelect' -import { VoiceRecorderStrip, type VoiceRecorderStripState } from './VoiceRecorderStrip' +import { VoiceConversationBar } from './VoiceConversationBar' import { PermissionModeToggle } from '@/components/session/PermissionModeToggle' import { SessionSandboxMenu } from '@/components/session/SessionSandboxMenu' import { ComposerToolsMenu } from './ComposerToolsMenu' @@ -45,7 +47,7 @@ import { isOpenSessionGoal } from '@opencode-manager/shared/schemas' import { useProviders } from '@/hooks/useProviders' -import type { CommandInfo } from '@opencode-manager/shared/opencode' +import type { CommandInfo, SessionInboxInfo, SessionMessageInfo, VoiceSessionStatus } from '@opencode-manager/shared/opencode' import type { FileAttachmentInfo, ImageAttachment } from '@/api/types' import { isBuiltinCommand, type CommandActions, type PageCommandActions } from '@/lib/builtinCommands' @@ -104,13 +106,6 @@ const COMPOSER_MODE_STYLES: Record ranges.map(([start, end]) => [start + offset, end + offset]) -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' - export interface PromptInputHandle { setPromptValue: (value: string) => void @@ -125,6 +120,10 @@ export interface PromptInputHandle { interface PromptInputProps { directory?: string sessionID: string + messages: SessionMessageInfo[] + pending: SessionInboxInfo[] + transcriptStatus: VoiceSessionStatus + onVoiceConversationActiveChange?: (active: boolean) => void showScrollButton?: boolean isSessionActive?: boolean isStreamingResponse?: boolean @@ -136,6 +135,10 @@ interface PromptInputProps { export const PromptInput = memo(forwardRef(function PromptInput({ directory, sessionID, + messages, + pending, + transcriptStatus, + onVoiceConversationActiveChange, showScrollButton, isSessionActive = false, isStreamingResponse = false, @@ -158,44 +161,18 @@ export const PromptInput = memo(forwardRef( const [selectedMentionIndex, setSelectedMentionIndex] = useState(0) const [selectedCommandIndex, setSelectedCommandIndex] = useState(0) const [localMode, setLocalMode] = useState(null) - const [isTogglingRecording, setIsTogglingRecording] = 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 voiceGestureStartXRef = useRef(null) - const voiceSendArmedRef = useRef(false) - const voiceCancelArmedRef = useRef(false) - const pendingVoiceAutoSubmitRef = useRef(false) - const ignoreVoiceClickUntilRef = useRef(0) - const voiceStartRequestRef = useRef(0) const handleSubmitRef = useRef<() => void>(() => {}) const promptRef = useRef(prompt) const attachedFilesRef = useRef(attachedFiles) const imageAttachmentsRef = useRef(imageAttachments) - const { - isRecording, - isProcessing, - startRecording, - stopRecording, - abortRecording, - isSupported: sttSupported, - isEnabled: sttEnabled, - interimTranscript, - transcript, - clear: clearSTT, - } = useSTT() + const sttAvailability = useSttAvailability() 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() @@ -206,18 +183,6 @@ export const PromptInput = memo(forwardRef( textareaRef.current?.focus() }, [hasFinePointer, sessionID]) - const resetVoiceGestureState = useCallback(() => { - voiceGestureStartXRef.current = null - voiceSendArmedRef.current = false - voiceCancelArmedRef.current = false - pendingVoiceAutoSubmitRef.current = false - ignoreVoiceClickUntilRef.current = 0 - setIsVoiceSendArmed(false) - setIsVoiceCancelArmed(false) - setIsVoiceAutoSendPending(false) - setIsVoiceAutoSendWaitingForTranscript(false) - }, []) - useEffect(() => { promptRef.current = prompt attachedFilesRef.current = attachedFiles @@ -239,8 +204,7 @@ export const PromptInput = memo(forwardRef( revokeBlobUrls(submittedImageAttachments) setImageAttachments([]) } - clearSTT() - }, [clearSTT]) + }, []) const pendingConfirmClearRef = useRef<{ prompt: string @@ -262,7 +226,8 @@ export const PromptInput = memo(forwardRef( const sessionAgent = useSessionAgent(sessionID, directory) const hasBackgroundActivity = useHasBackgroundActivity(sessionID) - const isLocalServer = isLocalOpenCodeServer(useOpenCodeServerId()) + const serverId = useOpenCodeServerId() + const isLocalServer = isLocalOpenCodeServer(serverId) const currentMode = localMode ?? sessionAgent.agent const setStoredAgent = useSessionAgentStore((s) => s.setAgent) const sendPrompt = useSendPrompt(directory) @@ -395,9 +360,6 @@ export const PromptInput = memo(forwardRef( if (!prompt.trim() && imageAttachments.length === 0) return if (startGoal.isPending) return - pendingVoiceAutoSubmitRef.current = false - setIsVoiceAutoSendPending(false) - if (isStreamingResponse) { onScrollToBottom() const parsed = parsePromptToInput(prompt, attachedFiles, agentNames, skillIds, imageAttachments) @@ -443,7 +405,6 @@ export const PromptInput = memo(forwardRef( if (promptRef.current !== submittedPrompt) return setPrompt('') setIsBashMode(false) - clearSTT() } } ) @@ -527,8 +488,16 @@ export const PromptInput = memo(forwardRef( handleSubmitRef.current = handleSubmit + const interruptTurn = useCallback( + (): Promise => + new Promise((resolve) => { + interruptSession.mutate(sessionID, { onSettled: () => resolve() }) + }), + [interruptSession, sessionID], + ) + const handleStop = () => { - interruptSession.mutate(sessionID) + void interruptTurn() } const focusPromptAt = useCallback((cursorPosition: number) => { @@ -617,298 +586,6 @@ export const PromptInput = memo(forwardRef( setMentionRange(null) } - const startVoiceRecording = async () => { - if (isRecording || isProcessing || isTogglingRecording) { - return - } - - const startRequestId = voiceStartRequestRef.current + 1 - voiceStartRequestRef.current = startRequestId - pendingVoiceAutoSubmitRef.current = false - setIsVoiceAutoSendPending(false) - setIsVoiceAutoSendWaitingForTranscript(false) - setIsVoiceSendArmed(false) - voiceSendArmedRef.current = false - setIsVoiceCancelArmed(false) - voiceCancelArmedRef.current = false - setIsTogglingRecording(true) - - const started = await startRecording() - if (voiceStartRequestRef.current !== startRequestId) { - setIsTogglingRecording(false) - if (started) { - abortRecording() - } - return - } - - if (!started) { - setIsTogglingRecording(false) - return - } - - if (typeof navigator !== 'undefined' && navigator.vibrate) { - navigator.vibrate(10) - } - textareaRef.current?.blur() - } - - const handleVoiceClick = async () => { - if (Date.now() < ignoreVoiceClickUntilRef.current) { - return - } - - if (isRecording) { - pendingVoiceAutoSubmitRef.current = false - setIsVoiceAutoSendPending(false) - setIsVoiceAutoSendWaitingForTranscript(false) - setIsVoiceSendArmed(false) - voiceSendArmedRef.current = false - setIsVoiceCancelArmed(false) - voiceCancelArmedRef.current = false - stopRecording() - return - } - - 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() - voiceGestureStartXRef.current = event.clientX - voiceSendArmedRef.current = false - voiceCancelArmedRef.current = false - setIsVoiceSendArmed(false) - setIsVoiceCancelArmed(false) - - if (event.currentTarget.setPointerCapture) { - event.currentTarget.setPointerCapture(event.pointerId) - } - } - - const handleVoicePointerMove = (event: ReactPointerEvent) => { - if (!isRecording || voiceGestureStartXRef.current === null) { - return - } - - event.preventDefault() - 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 (nextIsCancelArmed !== voiceCancelArmedRef.current) { - voiceCancelArmedRef.current = nextIsCancelArmed - setIsVoiceCancelArmed(nextIsCancelArmed) - - if (nextIsCancelArmed && typeof navigator !== 'undefined' && navigator.vibrate) { - navigator.vibrate(10) - } - } - } - - const handleVoicePointerEnd = (event: ReactPointerEvent, canceled = false) => { - if (event.currentTarget.releasePointerCapture && event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId) - } - - if (voiceGestureStartXRef.current === null) { - return - } - - event.preventDefault() - voiceGestureStartXRef.current = null - - const wasSendArmed = voiceSendArmedRef.current - const wasCancelArmed = voiceCancelArmedRef.current - clearVoiceGestureArmedState() - - if (canceled || (!wasSendArmed && !wasCancelArmed)) { - pendingVoiceAutoSubmitRef.current = false - setIsVoiceAutoSendPending(false) - setIsVoiceAutoSendWaitingForTranscript(false) - stopRecording() - return - } - - if (wasCancelArmed) { - cancelVoiceInput() - ignoreVoiceClickUntilRef.current = Date.now() + VOICE_CLICK_SUPPRESSION_MS - return - } - - handleVoiceSendRequest() - } - - useEffect(() => { - const isVoiceFeedbackVisible = isRecording || isTogglingRecording || isProcessing || isVoiceAutoSendPending - - if (!isVoiceFeedbackVisible) { - return - } - - const handleOutsidePointerDown = (event: PointerEvent) => { - const target = event.target - - if (!(target instanceof Node)) { - return - } - - if (voiceButtonContainerRef.current?.contains(target)) { - return - } - - if (isMobile && composerControlsRowRef.current?.contains(target)) { - return - } - - if (isRecording) { - voiceGestureStartXRef.current = null - voiceSendArmedRef.current = false - voiceCancelArmedRef.current = false - pendingVoiceAutoSubmitRef.current = false - setIsVoiceSendArmed(false) - setIsVoiceCancelArmed(false) - setIsVoiceAutoSendPending(false) - setIsVoiceAutoSendWaitingForTranscript(false) - stopRecording() - return - } - - if (isProcessing || isVoiceAutoSendPending) { - pendingVoiceAutoSubmitRef.current = false - setIsVoiceAutoSendPending(false) - setIsVoiceAutoSendWaitingForTranscript(false) - return - } - - cancelVoiceInput() - } - - document.addEventListener('pointerdown', handleOutsidePointerDown, true) - - return () => { - document.removeEventListener('pointerdown', handleOutsidePointerDown, true) - } - }, [cancelVoiceInput, isMobile, isProcessing, isRecording, isTogglingRecording, isVoiceAutoSendPending, stopRecording]) - - useEffect(() => { - const textToUse = transcript || interimTranscript - if (!isRecording && textToUse && textToUse !== 'Processing...' && textToUse !== 'Recording...') { - const trimmedTranscript = textToUse.trim() - if (trimmedTranscript && trimmedTranscript !== lastAddedTranscriptRef.current) { - if (prompt === '' || prompt === 'Processing...' || prompt === 'Recording...') { - setPrompt(trimmedTranscript) - } else { - setPrompt(prev => `${prev} ${trimmedTranscript}`) - } - lastAddedTranscriptRef.current = trimmedTranscript - - if (!pendingVoiceAutoSubmitRef.current) { - textareaRef.current?.focus() - } else { - setIsVoiceAutoSendWaitingForTranscript(false) - } - } - } - }, [isRecording, interimTranscript, transcript, prompt]) - - useEffect(() => { - if (isRecording && isTogglingRecording) { - setIsTogglingRecording(false) - } - }, [isRecording, isTogglingRecording]) - - useEffect(() => { - if (isTogglingRecording) { - lastAddedTranscriptRef.current = '' - } - }, [isTogglingRecording]) - - useEffect(() => { - setVoiceRecordingStartedAt(isRecording ? Date.now() : null) - }, [isRecording]) - - useEffect(() => { - if (!pendingVoiceAutoSubmitRef.current || isVoiceAutoSendWaitingForTranscript || isRecording || isProcessing || !prompt.trim()) { - return - } - - pendingVoiceAutoSubmitRef.current = false - setIsVoiceAutoSendPending(false) - setIsVoiceAutoSendWaitingForTranscript(false) - 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 - setIsVoiceAutoSendPending(false) - setIsVoiceAutoSendWaitingForTranscript(false) - } - }, [isRecording, isProcessing, transcript, interimTranscript]) - const addImageAttachment = (file: File) => { try { const reader = new FileReader() @@ -1186,13 +863,7 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) setImageAttachments([]) closeSuggestions() setIsBashMode(false) - resetVoiceGestureState() - if (isRecording) { - abortRecording() - } else { - clearSTT() - } - }, [imageAttachments, clearSTT, isRecording, abortRecording, resetVoiceGestureState, closeSuggestions]) + }, [imageAttachments, closeSuggestions]) const updateSuggestionTriggers = (value: string, cursorPosition: number) => { const mentionTrigger = detectMentionTrigger(value, cursorPosition) @@ -1422,104 +1093,67 @@ 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: VoiceRecorderStripState | null = isTogglingRecording - ? 'starting' - : isProcessing - ? isVoiceAutoSendPending - ? 'sending' - : 'processing' - : isRecording - ? isVoiceCancelArmed - ? 'cancelArmed' - : isVoiceSendArmed - ? 'readyToSend' - : 'recording' - : isVoiceAutoSendPending - ? 'sending' - : 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 && mobileVoiceRecorderState === null - const voiceButtonTitle = voiceFeedbackState === 'starting' - ? 'Starting microphone' - : voiceFeedbackState === 'sending' - ? 'Transcribing and sending speech' - : voiceFeedbackState === 'processing' - ? 'Transcribing speech' - : voiceFeedbackState === 'readyToSend' - ? 'Release to send' - : voiceFeedbackState === 'cancelArmed' - ? 'Release to discard' - : voiceFeedbackState === 'recording' - ? 'Tap to transcribe' - : 'Tap to speak' - - const handleSendVoiceRecording = () => { - if (isRecording) { - handleVoiceSendRequest() - return - } + const { pauseMs } = useVoicePauseSetting() - if (isProcessing && !isVoiceAutoSendPending) { - armVoiceAutoSend() - } - } + const sendVoiceTurn = useCallback( + async (text: string, options: { busy: boolean }): Promise => { + await sendPrompt.mutateAsync({ + sessionID, + text, + model: modelRef ?? undefined, + agent: currentMode, + ...(options.busy ? { delivery: 'queue' as const } : {}), + }) + setStoredAgent(sessionID, currentMode) + }, + [currentMode, modelRef, sendPrompt, sessionID, setStoredAgent], + ) + const handleVoiceEditInTextBox = useCallback((text: string) => { + const dictated = text.trim() + if (!dictated) return + setPrompt((current) => (current.trim() ? `${current} ${dictated}` : dictated)) + textareaRef.current?.focus() + }, []) + const voiceConversation = useVoiceConversation({ + sessionID, + serverId, + messages, + pending, + status: transcriptStatus, + hasPendingApproval: hasPendingPermissionForSession, + pauseMs, + sendTurn: sendVoiceTurn, + interrupt: interruptTurn, + onEditInTextBox: handleVoiceEditInTextBox, + }) - const renderVoiceButton = (variant: VoiceButtonVariant) => { - const isDesktop = variant === 'desktop' - const isBusy = isRecording || isTogglingRecording || (isProcessing && !isRecording) - const iconSizeClassName = isDesktop ? 'w-5 h-5' : 'w-6 h-6' - const spinnerClassName = `${iconSizeClassName} animate-spin rounded-full border-2 ${isDesktop ? 'border-muted-foreground' : 'border-foreground'} border-t-transparent` - const voiceGestureHandlers = isDesktop ? {} : { - onPointerDown: handleVoicePointerDown, - onPointerMove: handleVoicePointerMove, - onPointerUp: handleVoicePointerEnd, - onPointerCancel: (event: ReactPointerEvent) => handleVoicePointerEnd(event, true), - } - const buttonClassName = isDesktop - ? `hidden md:flex p-2 rounded-lg transition-all duration-200 active:scale-95 hover:scale-105 shadow-md border items-center justify-center touch-none select-none ${ - isBusy - ? '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-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' - }` - - const containerClassName = isDesktop ? 'relative hidden md:block' : 'relative flex touch-none select-none' - - return ( -
- -
- ) - } + useEffect(() => { + onVoiceConversationActiveChange?.(voiceConversation.isActive) + }, [onVoiceConversationActiveChange, voiceConversation.isActive]) + + const hasPromptContent = prompt.trim().length > 0 || imageAttachments.length > 0 + const showVoiceMic = sttAvailability.isEnabled && sttAvailability.isSupported && !hasPendingPermissionForSession + const showMobileKeys = isMobile && isPromptFocused && !hasPendingPermissionForSession + + const renderVoiceMic = () => ( + + ) const renderMobileKeys = () => (
@@ -1565,16 +1199,8 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) }, [prompt, onPromptChange]) useEffect(() => { - if (isRecording) { - abortRecording() - } - clearSTT() - resetVoiceGestureState() - lastAddedTranscriptRef.current = '' - setIsTogglingRecording(false) setLocalMode(null) setIsGoalArmed(false) - // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally only run on sessionID change to avoid clearing transcript when recording state changes }, [sessionID]) @@ -1600,6 +1226,14 @@ return ( {composerModeStyle.label} )} + {voiceConversation.isActive ? ( + setShowDialog(true)} + /> + ) : ( + <>