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 (
);
}
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`;
}