diff --git a/ui/src/components/ChatVoiceBadge.tsx b/ui/src/components/ChatVoiceBadge.tsx
new file mode 100644
index 00000000..2bd5e2cf
--- /dev/null
+++ b/ui/src/components/ChatVoiceBadge.tsx
@@ -0,0 +1,51 @@
+import { useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { ChatVoicePlayer } from "./ChatVoicePlayer";
+import { ChatMarkdownMessage } from "./ChatMarkdownMessage";
+
+interface ChatVoiceBadgeProps {
+ content: string;
+ messageType: string; // "voice_input" | "voice_full"
+ autoPlayVoice?: boolean;
+}
+
+export function ChatVoiceBadge({
+ content,
+ messageType,
+ autoPlayVoice = false,
+}: ChatVoiceBadgeProps) {
+ const [open, setOpen] = useState(false);
+
+ const spokenMatch = content.match(/SPOKEN:\s*([\s\S]*?)(?=\nDETAILED:|$)/);
+ const spokenText = spokenMatch?.[1]?.trim() ?? content;
+ const detailedMatch = content.match(/DETAILED:\s*([\s\S]*)/);
+
+ return (
+
+
+ Voice
+
+
{spokenText}
+ {messageType === "voice_full" && (
+ <>
+
+ {detailedMatch && (
+
+
+ {open ? "Hide full response" : "Show full response"}
+
+
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/ui/src/components/ChatVoicePlayer.tsx b/ui/src/components/ChatVoicePlayer.tsx
new file mode 100644
index 00000000..cab24677
--- /dev/null
+++ b/ui/src/components/ChatVoicePlayer.tsx
@@ -0,0 +1,122 @@
+import { useEffect, useRef, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Loader2, Pause, Play } from "lucide-react";
+
+interface ChatVoicePlayerProps {
+ text: string;
+ autoPlay?: boolean;
+}
+
+type PlayerStatus = "idle" | "loading" | "playing" | "paused";
+
+export function ChatVoicePlayer({ text, autoPlay = false }: ChatVoicePlayerProps) {
+ const [status, setStatus] = useState("loading");
+ const [audioUrl, setAudioUrl] = useState(null);
+ const audioRef = useRef(null);
+
+ useEffect(() => {
+ let objectUrl: string | null = null;
+ let cancelled = false;
+
+ async function fetchAudio() {
+ setStatus("loading");
+ try {
+ const res = await fetch("/api/synthesize", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({ text }),
+ });
+ if (cancelled) return;
+ if (!res.ok) {
+ setStatus("idle");
+ return;
+ }
+ const blob = await res.blob();
+ if (cancelled) return;
+ objectUrl = URL.createObjectURL(blob);
+ setAudioUrl(objectUrl);
+ setStatus("idle");
+ } catch {
+ if (!cancelled) {
+ setStatus("idle");
+ }
+ }
+ }
+
+ fetchAudio();
+
+ return () => {
+ cancelled = true;
+ if (objectUrl) {
+ URL.revokeObjectURL(objectUrl);
+ }
+ };
+ }, [text]);
+
+ useEffect(() => {
+ if (autoPlay && audioUrl && audioRef.current) {
+ audioRef.current.play().catch(() => {
+ // Browser may block autoplay; fall back to idle state
+ setStatus("idle");
+ });
+ }
+ }, [autoPlay, audioUrl]);
+
+ function handlePlay() {
+ if (audioRef.current) {
+ audioRef.current.play();
+ }
+ }
+
+ function handlePause() {
+ if (audioRef.current) {
+ audioRef.current.pause();
+ }
+ }
+
+ function handleAudioEnded() {
+ setStatus("idle");
+ if (audioUrl) {
+ URL.revokeObjectURL(audioUrl);
+ setAudioUrl(null);
+ }
+ }
+
+ if (status === "loading") {
+ return (
+
+
+ Loading audio...
+
+ );
+ }
+
+ return (
+
+ {status === "playing" ? (
+
+ ) : (
+
+ )}
+
+ );
+}