From 90efd342ef4ddb0acbfab5e176ab4ca48be3c87a Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 4 Apr 2026 02:44:51 +0000 Subject: [PATCH] feat(37-04): wire VoiceMicButton, VoiceModeToggle, ChatVoiceBadge, voiceMode into chat UI - ChatInput: replace VoiceRecordButton with VoiceMicButton (VAD-powered) - ChatInput: add VoiceModeToggle above input when enableVoiceInput=true - ChatMessage: add ChatVoiceBadge render for voice_input and voice_full messageTypes - ChatMessage: auto-play reads from localStorage nexus:voice:autoplay key - ChatPanel: import and call useVoiceMode, extract mode as voiceMode - ChatPanel: pass voiceMode as third arg to all startStream calls (5 call sites) --- ui/src/components/ChatInput.tsx | 8 +++++--- ui/src/components/ChatMessage.tsx | 32 +++++++++++++++++++++++++++++++ ui/src/components/ChatPanel.tsx | 10 ++++++---- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/ui/src/components/ChatInput.tsx b/ui/src/components/ChatInput.tsx index b5e1545d..56f1a2f2 100644 --- a/ui/src/components/ChatInput.tsx +++ b/ui/src/components/ChatInput.tsx @@ -4,7 +4,8 @@ import { Button } from "@/components/ui/button"; import { ChatSlashCommandPopover } from "./ChatSlashCommandPopover"; import { ChatMentionPopover } from "./ChatMentionPopover"; import { ChatFileDropZone } from "./ChatFileDropZone"; -import { VoiceRecordButton } from "./VoiceRecordButton"; +import { VoiceMicButton } from "./VoiceMicButton"; +import { VoiceModeToggle } from "./VoiceModeToggle"; import { cn } from "../lib/utils"; import type { Agent } from "@paperclipai/shared"; import type { PendingFile } from "../hooks/useChatFileUpload"; @@ -171,6 +172,7 @@ export function ChatInput({ onFilesDropped={(files) => files.forEach((f) => onFilesPicked?.([f]))} disabled={disabled} > + {enableVoiceInput && }
{ e.preventDefault(); @@ -243,8 +245,8 @@ export function ChatInput({ {/* Voice input button */} {enableVoiceInput && ( - )} diff --git a/ui/src/components/ChatMessage.tsx b/ui/src/components/ChatMessage.tsx index 32558e99..86749888 100644 --- a/ui/src/components/ChatMessage.tsx +++ b/ui/src/components/ChatMessage.tsx @@ -8,6 +8,7 @@ import { ChatHandoffIndicator } from "./ChatHandoffIndicator"; import { ChatTaskCreatedBadge } from "./ChatTaskCreatedBadge"; import { ChatStatusUpdateBadge } from "./ChatStatusUpdateBadge"; import { ChatFilePreview } from "./ChatFilePreview"; +import { ChatVoiceBadge } from "./ChatVoiceBadge"; import { Button } from "@/components/ui/button"; import { cn } from "../lib/utils"; import type { AgentRole, ChatFile } from "@paperclipai/shared"; @@ -85,6 +86,37 @@ export function ChatMessage({ return null; } } + if (messageType === "voice_input" || messageType === "voice_full") { + const autoPlay = typeof window !== "undefined" + ? localStorage.getItem("nexus:voice:autoplay") === "true" + : false; + return ( +
+ {agentName && ( + + )} + + {isStreaming && } + onRetry(id) : undefined} + onBookmark={id && onBookmark ? () => onBookmark(id) : undefined} + isBookmarked={isBookmarked} + /> +
+ ); + } // Fall through to default system message rendering (plain markdown) } diff --git a/ui/src/components/ChatPanel.tsx b/ui/src/components/ChatPanel.tsx index 74b23d3a..93a44da7 100644 --- a/ui/src/components/ChatPanel.tsx +++ b/ui/src/components/ChatPanel.tsx @@ -28,6 +28,7 @@ import { useMediaQuery } from "../hooks/useMediaQuery"; import { useOfflineQueue } from "../hooks/useOfflineQueue"; import { useOnlineStatus } from "../hooks/useOnlineStatus"; import { resolveAgentFromContent } from "../lib/slash-commands"; +import { useVoiceMode } from "../hooks/useVoiceMode"; import type { AgentRole } from "@paperclipai/shared"; export function ChatPanel() { @@ -41,6 +42,7 @@ export function ChatPanel() { const [searchOpen, setSearchOpen] = useState(false); const [bookmarksOpen, setBookmarksOpen] = useState(false); + const { mode: voiceMode } = useVoiceMode(); const { messages } = useChatMessages(activeConversationId); const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId); const { pendingFiles, addFile, removeFile, clearCompleted, completedFileIds } = useChatFileUpload(activeConversationId); @@ -178,7 +180,7 @@ export function ChatPanel() { queryClient.invalidateQueries({ queryKey: ["chat", "messages", newConvo.id] }); } queryClient.invalidateQueries({ queryKey: ["chat"] }); - startStream(content, resolvedAgentId ?? undefined); + startStream(content, resolvedAgentId ?? undefined, voiceMode); } else { // Path 2: Active conversation -- post user message then stream const message = await chatApi.postMessage(activeConversationId, { role: "user", content }); @@ -188,7 +190,7 @@ export function ChatPanel() { clearCompleted(); } queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); - startStream(content, resolvedAgentId ?? undefined); + startStream(content, resolvedAgentId ?? undefined, voiceMode); } } finally { setIsSending(false); @@ -216,7 +218,7 @@ export function ChatPanel() { await chatApi.truncateMessagesAfter(newConv.id, messageId); queryClient.invalidateQueries({ queryKey: ["chat", "messages", newConv.id] }); queryClient.invalidateQueries({ queryKey: ["chat", "search"] }); - startStream(newContent, activeAgentId ?? undefined); + startStream(newContent, activeAgentId ?? undefined, voiceMode); } catch { pushToast({ title: "Could not create branch. Try again.", tone: "error" }); } @@ -267,7 +269,7 @@ export function ChatPanel() { queryClient.invalidateQueries({ queryKey: ["chat", "search"] }); // Re-stream using the actual user message content - startStream(lastUserContent, activeAgentId ?? undefined); + startStream(lastUserContent, activeAgentId ?? undefined, voiceMode); }; // On mobile, render the full-screen MobileChatView instead of the desktop slide-in panel