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)
This commit is contained in:
Nexus Dev 2026-04-04 02:44:51 +00:00
parent 39bfec7fd8
commit 90efd342ef
3 changed files with 43 additions and 7 deletions

View file

@ -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 && <VoiceModeToggle />}
<form
onSubmit={(e) => {
e.preventDefault();
@ -243,8 +245,8 @@ export function ChatInput({
{/* Voice input button */}
{enableVoiceInput && (
<VoiceRecordButton
onTranscription={handleTranscription}
<VoiceMicButton
onTranscript={handleTranscription}
disabled={disabled}
/>
)}

View file

@ -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 (
<div className="max-w-full group relative">
{agentName && (
<ChatMessageIdentityBar
agentName={agentName}
agentIcon={agentIcon}
agentRole={agentRole}
timestamp={timestamp}
isStreaming={isStreaming}
/>
)}
<ChatVoiceBadge
content={content}
messageType={messageType}
autoPlayVoice={autoPlay}
/>
{isStreaming && <ChatStreamingCursor />}
<ChatMessageActions
role="assistant"
isStreaming={isAnyStreaming}
onRetry={id && onRetry ? () => onRetry(id) : undefined}
onBookmark={id && onBookmark ? () => onBookmark(id) : undefined}
isBookmarked={isBookmarked}
/>
</div>
);
}
// Fall through to default system message rendering (plain markdown)
}

View file

@ -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