diff --git a/ui/src/components/ChatInput.test.tsx b/ui/src/components/ChatInput.test.tsx index b43c6cd4..5bd958c7 100644 --- a/ui/src/components/ChatInput.test.tsx +++ b/ui/src/components/ChatInput.test.tsx @@ -4,6 +4,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ChatInput } from "./ChatInput"; +import { VoiceProvider } from "../context/VoiceContext"; // Tell React this environment uses act() for event flushing. // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -34,7 +35,11 @@ describe("ChatInput", () => { function renderChatInput(props: { onSend: (content: string) => void; isSubmitting?: boolean; disabled?: boolean }) { root = createRoot(container); act(() => { - root!.render(); + root!.render( + + + , + ); }); return { getTextarea: () => container.querySelector("textarea")!, @@ -177,7 +182,11 @@ describe("ChatInput", () => { const onFilesPicked = vi.fn(); root = createRoot(container); act(() => { - root!.render(); + root!.render( + + + , + ); }); const fileInput = container.querySelector("input[type='file']") as HTMLInputElement; expect(fileInput).not.toBeNull(); @@ -209,7 +218,11 @@ describe("ChatInput", () => { ]; root = createRoot(container); act(() => { - root!.render(); + root!.render( + + + , + ); }); const chip = container.querySelector(".bg-muted"); expect(chip).not.toBeNull(); diff --git a/ui/src/components/ChatInput.tsx b/ui/src/components/ChatInput.tsx index af6a9abb..c431bbcb 100644 --- a/ui/src/components/ChatInput.tsx +++ b/ui/src/components/ChatInput.tsx @@ -1,12 +1,13 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { Send, Loader2, Paperclip, X, WifiOff } from "lucide-react"; +import { Send, Loader2, Paperclip, X, WifiOff, Mic } from "lucide-react"; import { useSystemProviders } from "../hooks/useSystemProviders"; import { Button } from "@/components/ui/button"; import { ChatSlashCommandPopover } from "./ChatSlashCommandPopover"; import { ChatMentionPopover } from "./ChatMentionPopover"; import { ChatFileDropZone } from "./ChatFileDropZone"; -import { VoiceMicButton } from "./VoiceMicButton"; +import { VoiceWaveform } from "./VoiceWaveform"; import { VoiceModeToggle } from "./VoiceModeToggle"; +import { useVoice } from "../context/VoiceContext"; import { cn } from "../lib/utils"; import type { Agent } from "@paperclipai/shared"; import type { PendingFile } from "../hooks/useChatFileUpload"; @@ -42,6 +43,7 @@ export function ChatInput({ const [value, setValue] = useState(""); const textareaRef = useRef(null); const { providers } = useSystemProviders(); + const voice = useVoice(); // Slash command popover state const [slashOpen, setSlashOpen] = useState(false); @@ -112,6 +114,24 @@ export function ChatInput({ textareaRef.current?.focus(); }, []); + // Phase 14 — thin consumer of VoiceContext. The ChatInput mic no longer + // owns getUserMedia, MediaRecorder, or the transcription fetch; it just + // asks the shared provider to listen and hands any transcript back into + // the textarea via `onTranscript`. When the user is on the Assistant + // route the queue is bypassed (inline: true). + const handleVoiceToggle = useCallback(async () => { + if (voice.state === "listening") { + await voice.stopListening(); + return; + } + await voice.startListening({ + inline: true, + onTranscript: (text) => { + handleTranscription(text); + }, + }); + }, [voice, handleTranscription]); + function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Escape") { if (slashOpen) { @@ -245,12 +265,43 @@ export function ChatInput({ - {/* Voice input button */} + {/* Voice input button — Phase 14 thin consumer of VoiceContext. */} {enableVoiceInput && ( - + )} {/* Offline badge — shown when local Whisper model is detected */}