nexus/ui/src/components/ChatMessage.tsx
Nexus Dev 7d3820a84f 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)
2026-04-04 03:55:50 +00:00

230 lines
7.2 KiB
TypeScript

import { useState } from "react";
import { ChatMarkdownMessage } from "./ChatMarkdownMessage";
import { ChatMessageIdentityBar } from "./ChatMessageIdentityBar";
import { ChatStreamingCursor } from "./ChatStreamingCursor";
import { ChatMessageActions } from "./ChatMessageActions";
import { ChatSpecCard } from "./ChatSpecCard";
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";
interface ChatMessageProps {
id?: string;
role: "user" | "assistant" | "system";
content: string;
messageType?: string | null;
conversationId?: string;
agentName?: string | null;
agentIcon?: string | null;
agentRole?: AgentRole | null;
timestamp?: string;
isStreaming?: boolean;
isAnyStreaming?: boolean;
onEdit?: (messageId: string, newContent: string) => void;
onRetry?: (messageId: string) => void;
onHandoff?: (spec: { what: string; why: string; constraints: string; success: string }) => void;
onBookmark?: (messageId: string) => void;
isBookmarked?: boolean;
files?: ChatFile[];
}
export function ChatMessage({
id,
role,
content,
messageType,
conversationId,
agentName,
agentIcon,
agentRole,
timestamp,
isStreaming,
isAnyStreaming,
onEdit,
onRetry,
onHandoff,
onBookmark,
isBookmarked,
files,
}: ChatMessageProps) {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(content);
// Dispatch to specialized system message components (Phase 23)
if (role === "system" || messageType) {
if (messageType === "spec_card") {
return (
<ChatSpecCard
content={content}
messageId={id}
conversationId={conversationId}
onHandoff={onHandoff}
/>
);
}
if (messageType === "handoff") {
return <ChatHandoffIndicator content={content} />;
}
if (messageType === "task_created") {
try {
const data = JSON.parse(content) as { taskId?: string; taskTitle?: string; taskUrl?: string };
return <ChatTaskCreatedBadge taskId={data.taskId} taskTitle={data.taskTitle} taskUrl={data.taskUrl} />;
} catch {
return <ChatTaskCreatedBadge />;
}
}
if (messageType === "status_update") {
try {
const data = JSON.parse(content) as { agentName: string; taskId: string; taskTitle?: string; taskUrl?: string };
return <ChatStatusUpdateBadge agentName={data.agentName} taskId={data.taskId} taskTitle={data.taskTitle} taskUrl={data.taskUrl} />;
} catch {
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)
}
if (role === "user") {
if (isEditing) {
return (
<div className="flex justify-end">
<div className="max-w-[85%] w-full">
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm resize-none min-h-[40px] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label="Edit your message"
rows={3}
/>
<div className="flex justify-end gap-2 mt-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsEditing(false);
setEditValue(content);
}}
>
Discard edit
</Button>
<Button
variant="default"
size="sm"
disabled={!editValue.trim()}
onClick={() => {
if (id && onEdit && editValue.trim()) {
onEdit(id, editValue.trim());
setIsEditing(false);
}
}}
>
Save edit
</Button>
</div>
</div>
</div>
);
}
return (
<div className="flex justify-end">
<div
className={cn(
"relative group max-w-[85%] rounded-lg bg-secondary px-3 py-2 text-secondary-foreground text-sm",
)}
>
{content}
{files && files.length > 0 && (
<div className="mt-2 flex flex-col gap-2">
{files.map((f) => (
<ChatFilePreview
key={f.id}
file={f}
contentPath={`/api/files/${f.id}/content`}
/>
))}
</div>
)}
<ChatMessageActions
role="user"
isStreaming={isAnyStreaming}
onEdit={() => setIsEditing(true)}
onBookmark={id && onBookmark ? () => onBookmark(id) : undefined}
isBookmarked={isBookmarked}
/>
</div>
</div>
);
}
// assistant or system
return (
<div className="max-w-full group relative">
{agentName && (
<ChatMessageIdentityBar
agentName={agentName}
agentIcon={agentIcon}
agentRole={agentRole}
timestamp={timestamp}
isStreaming={isStreaming}
/>
)}
<ChatMarkdownMessage content={content} />
{files && files.length > 0 && (
<div className="mt-2 flex flex-col gap-2">
{files.map((f) => (
<ChatFilePreview
key={f.id}
file={f}
contentPath={`/api/files/${f.id}/content`}
/>
))}
</div>
)}
{isStreaming && <ChatStreamingCursor />}
<ChatMessageActions
role="assistant"
isStreaming={isAnyStreaming}
onRetry={id && onRetry ? () => onRetry(id) : undefined}
onBookmark={id && onBookmark ? () => onBookmark(id) : undefined}
isBookmarked={isBookmarked}
/>
</div>
);
}