refactor(nexus): ChatInput voice mic consumes VoiceContext

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 <VoiceProvider silenceErrors> so the
useVoice() hook resolves in jsdom.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 13:23:22 +00:00
parent c417ce37f9
commit 2a950dedd0
2 changed files with 74 additions and 10 deletions

View file

@ -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(<ChatInput {...props} />);
root!.render(
<VoiceProvider silenceErrors>
<ChatInput {...props} />
</VoiceProvider>,
);
});
return {
getTextarea: () => container.querySelector("textarea")!,
@ -177,7 +182,11 @@ describe("ChatInput", () => {
const onFilesPicked = vi.fn();
root = createRoot(container);
act(() => {
root!.render(<ChatInput onSend={onSend} onFilesPicked={onFilesPicked} />);
root!.render(
<VoiceProvider silenceErrors>
<ChatInput onSend={onSend} onFilesPicked={onFilesPicked} />
</VoiceProvider>,
);
});
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(<ChatInput onSend={onSend} pendingFiles={pendingFiles} />);
root!.render(
<VoiceProvider silenceErrors>
<ChatInput onSend={onSend} pendingFiles={pendingFiles} />
</VoiceProvider>,
);
});
const chip = container.querySelector(".bg-muted");
expect(chip).not.toBeNull();

View file

@ -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<HTMLTextAreaElement>(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<HTMLTextAreaElement>) {
if (e.key === "Escape") {
if (slashOpen) {
@ -245,12 +265,43 @@ export function ChatInput({
</Button>
</label>
{/* Voice input button */}
{/* Voice input button — Phase 14 thin consumer of VoiceContext. */}
{enableVoiceInput && (
<VoiceMicButton
onTranscript={handleTranscription}
disabled={disabled}
/>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"h-8 w-8",
voice.state === "listening" && "ring-2 ring-primary",
)}
onClick={() => {
void handleVoiceToggle();
}}
disabled={disabled || voice.state === "speaking"}
aria-label={
voice.state === "listening"
? "Recording — speak now"
: voice.state === "speaking"
? "Transcribing..."
: "Start voice input"
}
title={
voice.state === "listening"
? "Recording — speak now"
: voice.state === "speaking"
? "Transcribing..."
: "Start voice input"
}
>
{voice.state === "listening" ? (
<VoiceWaveform stream={voice.mediaStream} active={true} />
) : voice.state === "speaking" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Mic className="h-4 w-4" />
)}
</Button>
)}
{/* Offline badge — shown when local Whisper model is detected */}