"use client"; import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { Loader2, RotateCcw, Undo2, X } from "lucide-react"; import { usePathname } from "next/navigation"; import { type ReactNode, useEffect, useState } from "react"; import { toast } from "sonner"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button, buttonVariants } from "@/components/ui/button"; import { type LivePodcast, usePodcastLive } from "@/hooks/use-podcast-live"; import { podcastsApiService } from "@/lib/apis/podcasts-api.service"; import { BriefReview } from "./brief-review"; import { PodcastErrorState, PodcastPlayer } from "./player"; import type { GeneratePodcastArgs, GeneratePodcastResult } from "./schema"; function WorkingState({ title, label, action, }: { title: string; label: string; action?: ReactNode; }) { return (

{title}

{action}
); } function NoticeState({ title, message }: { title: string; message: string }) { return (

{title}

{message}

); } /** * Regenerating reopens the brief and ultimately replaces the current audio, * so a stray click is guarded by an inline confirm step. */ function RegenerateButton({ podcast }: { podcast: LivePodcast }) { const [confirming, setConfirming] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const regenerate = async () => { setIsSubmitting(true); try { await podcastsApiService.regenerate(podcast.id); } catch (error) { toast.error(error instanceof Error ? error.message : "Failed to regenerate the podcast"); } finally { setIsSubmitting(false); setConfirming(false); } }; if (!confirming) { return ( ); } return (
Reopen the brief and replace this episode?
); } /** * The way out of an in-flight generation depends on what already exists: * a regeneration is reverted (the stored episode survives, so no confirm), * while a first-time generation is cancelled (destructive, so confirmed via a * dialog — the card header is too cramped to host a confirmation row). */ function BackOutButton({ podcastId, hasEpisode }: { podcastId: number; hasEpisode: boolean }) { const [isSubmitting, setIsSubmitting] = useState(false); const run = async (call: (id: number) => Promise, failure: string) => { setIsSubmitting(true); try { await call(podcastId); } catch (error) { toast.error(error instanceof Error ? error.message : failure); } finally { setIsSubmitting(false); } }; if (hasEpisode) { return ( ); } return ( Cancel this podcast? Generation stops and the podcast is discarded. This cannot be undone. Keep going run(podcastsApiService.cancel, "Failed to cancel the podcast")} > Cancel podcast ); } const BACK_OUT_STATUSES = new Set(["awaiting_brief", "drafting", "rendering"]); /** Status-driven card for an authenticated viewer, fed by Zero push. */ function LivePodcastCard({ podcastId, fallbackTitle, }: { podcastId: number; fallbackTitle: string; }) { const { podcast, isLoading } = usePodcastLive(podcastId); // Whether a finished episode exists decides revert-vs-cancel, and Zero // doesn't publish audio fields — so the in-flight states check over REST, // re-checking on each status change (a fresh podcast gains its episode, // a regeneration starts with one). const status = podcast?.status; const [hasEpisode, setHasEpisode] = useState(false); useEffect(() => { if (!status || !BACK_OUT_STATUSES.has(status)) return; let stale = false; podcastsApiService .getDetail(podcastId) .then((detail) => { if (!stale) setHasEpisode(detail.has_audio); }) .catch(() => {}); return () => { stale = true; }; }, [podcastId, status]); if (!podcast) { if (isLoading) { return ; } return ( ); } const title = podcast.title || fallbackTitle; const backOut = ; switch (podcast.status) { case "pending": return ; case "drafting": return ; case "rendering": return ; case "awaiting_brief": // The gate lives right in the chat: the form is the card, so there // is nothing to open and nothing to dismiss. if (!podcast.spec) { return ; } return (

{title}

Confirm the language, voices, and length — the episode generates automatically after you approve.

{backOut}
); case "awaiting_review": // Legacy rows parked at the removed transcript gate; the only way // forward is regenerating through the brief gate. return (

{title}

This podcast was drafted before audio rendering became automatic.

); case "ready": return (
); case "failed": return ; case "cancelled": return ; } } /** * Tool UI for `generate_podcast`. The tool only prepares the podcast (it * returns with the brief awaiting review), so this card follows the lifecycle * by Zero push, rendering the brief form inline at the gate. Public shared * chats have no Zero session; their snapshots only ever contain finished * episodes, so the player renders directly against the share-token endpoints. */ export const GeneratePodcastToolUI = ({ args, result, status, }: ToolCallMessagePartProps) => { const pathname = usePathname(); const isPublicRoute = !!pathname?.startsWith("/public/"); const title = args.podcast_title || "SurfSense Podcast"; if (status.type === "running" || status.type === "requires-action") { return ; } if (status.type === "incomplete") { if (status.reason === "cancelled") { return ; } if (status.reason === "error") { return ( ); } } if (!result) { return ; } if (result.podcast_id) { if (isPublicRoute) { return ; } return ; } if (result.status === "failed" || result.status === "error") { return ; } // Legacy saved chats: results identified only by a Celery task id can't be // recovered through the lifecycle API. return ( ); };