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:
parent
c417ce37f9
commit
2a950dedd0
2 changed files with 74 additions and 10 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue