import { createFileRoute, Link } from "@tanstack/react-router"; import { SitePreview } from "@/components/site-preview"; import { queryOptions, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import { Mic, MicOff, PhoneOff, MessagesSquare, Clock, AudioLines, ExternalLink } from "lucide-react"; import { supabase } from "@/integrations/supabase/client"; import type { LiveEvent } from "@/hooks/use-live-voice"; import { useGeminiLive } from "@/hooks/use-gemini-live"; import { Button } from "@/components/ui/button"; import { SignIn } from "@/components/sign-in"; import { useAuth, displayName } from "@/hooks/use-auth"; type Conversation = { id: string; session_id: string | null; started_at: string; ended_at: string; turn_count: number; voice_messages: { position: number; role: string; content: string }[]; }; const historyQuery = queryOptions({ queryKey: ["voice-history"], queryFn: async (): Promise => { const { data, error } = await supabase .from("voice_conversations") .select("id, session_id, started_at, ended_at, turn_count, voice_messages(position, role, content)") .order("started_at", { ascending: false }) .limit(50); if (error) throw error; return data ?? []; }, }); export const Route = createFileRoute("/echo")({ head: () => ({ meta: [ { title: "Echo — Live Voice Agent | Codesynux" }, { name: "description", content: "Talk to Echo with your voice. Every conversation is saved to a live history dashboard you can ask about." }, { property: "og:title", content: "Echo — Hands-free Voice Assistant" }, { property: "og:description", content: "Talk, don't type. A voice assistant with a live conversation history dashboard." }, { property: "og:type", content: "website" }, { name: "twitter:card", content: "summary_large_image" }, ], }), component: Index, }); type Caption = { role: "user" | "assistant"; text: string }; function Index() { const queryClient = useQueryClient(); const [captions, setCaptions] = useState([]); type Site = { html: string; id: string; building: string | null; error: string | null; autoFix: boolean; url: string | null; deploying: boolean }; const empty: Site = { html: "", id: "", building: null, error: null, autoFix: false, url: null, deploying: false }; const [site, setSite] = useState(empty); const onEvent = useCallback( (event: LiveEvent) => { if (event.type === "app.started") { setCaptions([]); setSite(empty); } if (event.type === "app.build.start") setSite((p) => ({ ...p, building: event["fix"] ? "Fixing the issues live testing found" : String(event["request"] ?? ""), error: null })); // A voice build may be auto-fixed once; a fix result is never auto-fixed again (no loops). if (event.type === "app.build.done") setSite((p) => ({ ...p, html: String(event["html"] ?? ""), id: String(event["id"] ?? ""), building: null, error: null, autoFix: !event["fix"], url: null })); if (event.type === "app.build.error") setSite((p) => ({ ...p, building: null, error: String(event["message"] ?? "Build failed") })); if (event.type === "app.deploy.start") setSite((p) => ({ ...p, deploying: true })); if (event.type === "app.deploy.done") setSite((p) => ({ ...p, deploying: false, url: String(event["url"] ?? "") })); if (event.type === "app.deploy.error") setSite((p) => ({ ...p, deploying: false, error: String(event["message"] ?? "Publishing failed") })); if (event.type === "session.input_transcript.delta" || event.type === "session.output_transcript.delta") { const role = event.type === "session.input_transcript.delta" ? "user" : "assistant"; const delta = typeof event["delta"] === "string" ? event["delta"] : ""; if (!delta) return; setCaptions((prev) => { const last = prev[prev.length - 1]; if (last && last.role === role) return [...prev.slice(0, -1), { role, text: last.text + delta }]; return [...prev, { role, text: delta }]; }); } if (event.type === "app.closed") { setTimeout(() => queryClient.invalidateQueries({ queryKey: ["voice-history"] }), 1500); } }, [queryClient], ); const voice = useGeminiLive({ onEvent }); const idle = voice.status === "idle" || voice.status === "closed"; const live = voice.status === "connected"; const { user, ready } = useAuth(); const name = displayName(user); const [entered, setEntered] = useState(false); useEffect(() => { queryClient.invalidateQueries({ queryKey: ["voice-history"] }); }, [user?.id, queryClient]); const auto = useRef(false); useEffect(() => { const ua = (navigator as Navigator & { userActivation?: { hasBeenActive: boolean } }).userActivation; if (user && !auto.current && ua?.hasBeenActive) { auto.current = true; setEntered(true); voice.start(name); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [user]); const connect = () => { setEntered(true); voice.start(name); }; const label = { idle: "Ready", connecting: "Connecting…", connected: voice.muted ? "Muted" : voice.phase === "speaking" ? "Speaking…" : `Listening to you, ${name}…`, stopping: "Ending call…", closed: "Call ended", }[voice.status]; if (!ready) return
Loading…
; if (!user) return ; if (!entered) { return (

Hi {name}. Ready to talk?

Echo will greet you and start listening right away.

); } return (

Talk. Don't type.

Ask Echo about code, everyday questions, or your past conversations.

{voice.muted || idle ? : }

{label}

{live && ( )} {idle ? ( ) : ( )}
{voice.error &&

{voice.error}

} {(site.html || site.building || site.error) && (
{site.building &&

Building: {site.building}…

} {site.error &&

{site.error}

} {site.deploying &&

Publishing to Cloudflare…

} {site.html && ( { setSite((p) => ({ ...p, autoFix: false })); void voice.fixBuild(errors); }} onTestResult={voice.reportTest} /> )} {voice.sessionId && site.html && ( Shareable session page (full chat appears after the call ends) )}
)} {captions.length > 0 && (
{captions.slice(-6).map((c, i) => (

{c.role === "user" ? "you" : "echo"} {c.text}

))}
)}
Loading history…

}>
); } function Dashboard() { const { data } = useSuspenseQuery(historyQuery); const [open, setOpen] = useState(null); const totalTurns = data.reduce((n, c) => n + c.turn_count, 0); const last = data[0]; return (

Dashboard · history

{data.length === 0 ? (
No conversations yet. Tap the button above and say hello.
) : (
    {data.map((c) => { const msgs = [...c.voice_messages].sort((a, b) => a.position - b.position); const first = msgs.find((m) => m.role === "user")?.content ?? "Conversation"; const isOpen = open === c.id; return (
  • {isOpen && (
    {c.session_id && ( Open session page )} {msgs.map((m) => (

    {m.role === "user" ? "visitor" : "echo"} {m.content}

    ))}
    )}
  • ); })}
)}
); } function Stat({ label, value }: { label: string; value: string }) { return (

{value}

{label}

); } function timeAgo(iso: string) { const mins = Math.round((Date.now() - new Date(iso).getTime()) / 60000); if (mins < 1) return "now"; if (mins < 60) return `${mins}m`; const h = Math.round(mins / 60); return h < 24 ? `${h}h` : `${Math.round(h / 24)}d`; }