From 2a950dedd046195116fb143bd2f56f15f8e4054f Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 13:23:22 +0000 Subject: [PATCH] refactor(nexus): ChatInput voice mic consumes VoiceContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline voice button is now a thin consumer of VoiceContext: it calls startListening({ inline: true, onTranscript }) so the transcript is inserted into the textarea instead of being queued for the Assistant inbox. The useVadRecorder-based VoiceMicButton import is dropped — getUserMedia, MediaRecorder, and the /api/transcribe fetch all live in VoiceContext now. VoiceWaveform is still rendered while listening, driven by the shared mediaStream from the provider. Tests wrap ChatInput in so the useVoice() hook resolves in jsdom. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/components/ChatInput.test.tsx | 19 ++++++-- ui/src/components/ChatInput.tsx | 65 +++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 10 deletions(-) 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 */}