"use client"; import { useAtomValue, useSetAtom } from "jotai"; import { ChevronDownIcon, XIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { z } from "zod"; import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { PlateEditor } from "@/components/editor/plate-editor"; import { MarkdownViewer } from "@/components/markdown-viewer"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useMediaQuery } from "@/hooks/use-media-query"; import { baseApiService } from "@/lib/apis/base-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; /** * Zod schema for a single version entry */ const VersionInfoSchema = z.object({ id: z.number(), created_at: z.string().nullish(), }); /** * Zod schema for the report content API response */ const ReportContentResponseSchema = z.object({ id: z.number(), title: z.string(), content: z.string().nullish(), report_metadata: z .object({ status: z.enum(["ready", "failed"]).nullish(), error_message: z.string().nullish(), word_count: z.number().nullish(), char_count: z.number().nullish(), section_count: z.number().nullish(), }) .nullish(), report_group_id: z.number().nullish(), versions: z.array(VersionInfoSchema).nullish(), }); type ReportContentResponse = z.infer; type VersionInfo = z.infer; /** * Shimmer loading skeleton for report panel */ function ReportPanelSkeleton() { return (
{/* Title skeleton */}
{/* Paragraph 1 */}
{/* Heading */}
{/* Paragraph 2 */}
{/* Heading */}
{/* Paragraph 3 */}
); } /** * Inner content component used by both desktop panel and mobile drawer */ function ReportPanelContent({ reportId, title, onClose, insideDrawer = false, shareToken, }: { reportId: number; title: string; onClose?: () => void; /** When true, adjusts dropdown behavior to work inside a Vaul drawer on mobile */ insideDrawer?: boolean; /** When set, uses public endpoint for fetching report data (public shared chat) */ shareToken?: string | null; }) { const [reportContent, setReportContent] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); const [exporting, setExporting] = useState<"pdf" | "docx" | "md" | null>(null); const [saving, setSaving] = useState(false); // Editor state — tracks the latest markdown from the Plate editor const [editedMarkdown, setEditedMarkdown] = useState(null); // Read-only when public (shareToken) OR shared (SEARCH_SPACE visibility) const currentThreadState = useAtomValue(currentThreadAtom); const isReadOnly = !!shareToken || currentThreadState.visibility === "SEARCH_SPACE"; // Version state const [activeReportId, setActiveReportId] = useState(reportId); const [versions, setVersions] = useState([]); // Reset active version when the external reportId changes (e.g. clicking a different card) useEffect(() => { setActiveReportId(reportId); }, [reportId]); // Fetch report content (re-runs when activeReportId changes for version switching) useEffect(() => { let cancelled = false; const fetchContent = async () => { setIsLoading(true); setError(null); try { const url = shareToken ? `/api/v1/public/${shareToken}/reports/${activeReportId}/content` : `/api/v1/reports/${activeReportId}/content`; const rawData = await baseApiService.get(url); if (cancelled) return; const parsed = ReportContentResponseSchema.safeParse(rawData); if (parsed.success) { // Check if the report was marked as failed in metadata if (parsed.data.report_metadata?.status === "failed") { setError(parsed.data.report_metadata?.error_message || "Report generation failed"); } else { setReportContent(parsed.data); // Update versions from the response if (parsed.data.versions && parsed.data.versions.length > 0) { setVersions(parsed.data.versions); } } } else { console.warn("Invalid report content response:", parsed.error.issues); setError("Invalid response format"); } } catch (err) { if (cancelled) return; console.error("Error fetching report content:", err); setError(err instanceof Error ? err.message : "Failed to load report"); } finally { if (!cancelled) setIsLoading(false); } }; fetchContent(); return () => { cancelled = true; }; }, [activeReportId, shareToken]); // The current markdown: use edited version if available, otherwise original const currentMarkdown = editedMarkdown ?? reportContent?.content ?? null; // Reset edited markdown when switching versions or reports useEffect(() => { setEditedMarkdown(null); }, [activeReportId]); // Copy markdown content (uses latest editor content) const handleCopy = useCallback(async () => { if (!currentMarkdown) return; try { await navigator.clipboard.writeText(currentMarkdown); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (err) { console.error("Failed to copy:", err); } }, [currentMarkdown]); // Export report const handleExport = useCallback( async (format: "pdf" | "docx" | "md") => { setExporting(format); const safeTitle = title .replace(/[^a-zA-Z0-9 _-]/g, "_") .trim() .slice(0, 80) || "report"; try { if (format === "md") { // Download markdown content directly as a .md file (uses latest editor content) if (!currentMarkdown) return; const blob = new Blob([currentMarkdown], { type: "text/markdown;charset=utf-8", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${safeTitle}.md`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } else { const response = await authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${activeReportId}/export?format=${format}`, { method: "GET" } ); if (!response.ok) { throw new Error(`Export failed: ${response.status}`); } const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${safeTitle}.${format}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } } catch (err) { console.error(`Export ${format} failed:`, err); } finally { setExporting(null); } }, [activeReportId, title, currentMarkdown] ); // Save edited report content const handleSave = useCallback(async () => { if (!currentMarkdown || !activeReportId) return; setSaving(true); try { const response = await authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${activeReportId}/content`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: currentMarkdown }), } ); if (!response.ok) { const errorData = await response.json().catch(() => ({ detail: "Failed to save report" })); throw new Error(errorData.detail || "Failed to save report"); } // Update local state to reflect saved content setReportContent((prev) => (prev ? { ...prev, content: currentMarkdown } : prev)); setEditedMarkdown(null); toast.success("Report saved successfully"); } catch (err) { console.error("Error saving report:", err); toast.error(err instanceof Error ? err.message : "Failed to save report"); } finally { setSaving(false); } }, [activeReportId, currentMarkdown]); // Show full-page skeleton only on initial load (no data loaded yet). // Once we have versions/content from a prior fetch, keep the action bar visible. const hasLoadedBefore = versions.length > 0 || reportContent !== null; if (isLoading && !hasLoadedBefore) { return ( <> {/* Minimal top bar with close button even during initial load */}
{onClose && ( )}
); } const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId); return ( <> {/* Action bar — always visible after initial load */}
{/* Copy button */} {/* Export dropdown */} handleExport("md")}> Download Markdown {/* PDF/DOCX export requires server-side conversion via authenticated endpoint. Hide for public viewers who have no auth token. */} {!shareToken && ( <> handleExport("pdf")} disabled={exporting !== null} > Download PDF handleExport("docx")} disabled={exporting !== null} > Download DOCX )} {/* Version switcher — only shown when multiple versions exist */} {versions.length > 1 && ( {versions.map((v, i) => ( setActiveReportId(v.id)} className={v.id === activeReportId ? "bg-accent font-medium" : ""} > Version {i + 1} ))} )}
{onClose && ( )}
{/* Report content — skeleton/error/viewer/editor shown only in this area */}
{isLoading ? ( ) : error || !reportContent ? (

Failed to load report

{error || "An unknown error occurred"}

) : reportContent.content ? ( isReadOnly ? (
) : ( ) ) : (

No content available.

)}
); } /** * Desktop report panel — renders as a right-side flex sibling */ function DesktopReportPanel() { const panelState = useAtomValue(reportPanelAtom); const closePanel = useSetAtom(closeReportPanelAtom); const panelRef = useRef(null); // Close panel on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { closePanel(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [closePanel]); if (!panelState.isOpen || !panelState.reportId) return null; return (
); } /** * Mobile report drawer — uses Vaul (same pattern as comment sheet) */ function MobileReportDrawer() { const panelState = useAtomValue(reportPanelAtom); const closePanel = useSetAtom(closeReportPanelAtom); if (!panelState.reportId) return null; return ( { if (!open) closePanel(); }} shouldScaleBackground={false} > {panelState.title || "Report"}
); } /** * ReportPanel — responsive report viewer * * On desktop (lg+): Renders as a right-side split panel (flex sibling to the chat thread) * On mobile/tablet: Renders as a Vaul bottom drawer * * When open on desktop, the comments gutter is automatically suppressed * (handled via showCommentsGutterAtom in current-thread.atom.ts) */ export function ReportPanel() { const panelState = useAtomValue(reportPanelAtom); const isDesktop = useMediaQuery("(min-width: 1024px)"); // Don't render anything if panel is not open if (!panelState.isOpen || !panelState.reportId) return null; if (isDesktop) { return ; } return ; }