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 */}