-
} />
+
diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx
new file mode 100644
index 000000000..2755117ca
--- /dev/null
+++ b/surfsense_web/components/report-panel/report-panel.tsx
@@ -0,0 +1,399 @@
+"use client";
+
+import { useAtomValue, useSetAtom } from "jotai";
+import {
+ CheckIcon,
+ ClipboardIcon,
+ DownloadIcon,
+ FileTextIcon,
+ Loader2Icon,
+ XIcon,
+} from "lucide-react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { z } from "zod";
+import {
+ closeReportPanelAtom,
+ reportPanelAtom,
+} from "@/atoms/chat/report-panel.atom";
+import { Button } from "@/components/ui/button";
+import {
+ Drawer,
+ DrawerContent,
+ DrawerHandle,
+} from "@/components/ui/drawer";
+import { MarkdownViewer } from "@/components/markdown-viewer";
+import { useMediaQuery } from "@/hooks/use-media-query";
+import { baseApiService } from "@/lib/apis/base-api.service";
+import { authenticatedFetch } from "@/lib/auth-utils";
+
+/**
+ * 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({
+ sections: z
+ .array(
+ z.object({
+ level: z.number(),
+ title: z.string(),
+ })
+ )
+ .nullish(),
+ word_count: z.number().nullish(),
+ char_count: z.number().nullish(),
+ section_count: z.number().nullish(),
+ })
+ .nullish(),
+});
+
+type ReportContentResponse = 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,
+ wordCount,
+ onClose,
+}: {
+ reportId: number;
+ title: string;
+ wordCount: number | null;
+ onClose?: () => void;
+}) {
+ 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" | null>(null);
+
+ // Fetch report content
+ useEffect(() => {
+ let cancelled = false;
+ const fetchContent = async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ const rawData = await baseApiService.get(
+ `/api/v1/reports/${reportId}/content`
+ );
+ if (cancelled) return;
+ const parsed = ReportContentResponseSchema.safeParse(rawData);
+ if (parsed.success) {
+ setReportContent(parsed.data);
+ } 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;
+ };
+ }, [reportId]);
+
+ // Copy markdown content
+ const handleCopy = useCallback(async () => {
+ if (!reportContent?.content) return;
+ try {
+ await navigator.clipboard.writeText(reportContent.content);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error("Failed to copy:", err);
+ }
+ }, [reportContent?.content]);
+
+ // Export report
+ const handleExport = useCallback(
+ async (format: "pdf" | "docx") => {
+ setExporting(format);
+ try {
+ const response = await authenticatedFetch(
+ `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${reportId}/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 = `${title.replace(/[^a-zA-Z0-9 _-]/g, "_").trim().slice(0, 80) || "report"}.${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);
+ }
+ },
+ [reportId, title]
+ );
+
+ const displayWordCount =
+ wordCount ?? reportContent?.report_metadata?.word_count ?? null;
+ const displayTitle = reportContent?.title || title;
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error || !reportContent) {
+ return (
+
+
+
+
+
+
Failed to load report
+
+ {error || "An unknown error occurred"}
+
+
+
+ );
+ }
+
+ return (
+ <>
+ {/* Action bar */}
+
+
+ {displayWordCount != null && (
+
+ {displayWordCount.toLocaleString()} words
+ {reportContent.report_metadata?.section_count
+ ? ` · ${reportContent.report_metadata.section_count} sections`
+ : ""}
+
+ )}
+
+
+
+
+ {onClose && (
+
+ )}
+
+
+ {/* Report content */}
+
+
+
{displayTitle}
+ {reportContent.content ? (
+
+ ) : (
+
+ 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}
+ >
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * 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 ;
+}
+
diff --git a/surfsense_web/components/tool-ui/generate-report.tsx b/surfsense_web/components/tool-ui/generate-report.tsx
index 5b21f67b1..705219b65 100644
--- a/surfsense_web/components/tool-ui/generate-report.tsx
+++ b/surfsense_web/components/tool-ui/generate-report.tsx
@@ -1,20 +1,18 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
+import { useAtomValue, useSetAtom } from "jotai";
import {
- CheckIcon,
- ClipboardIcon,
- DownloadIcon,
FileTextIcon,
Loader2Icon,
} from "lucide-react";
-import { useCallback, useEffect, useState } from "react";
+import { useEffect, useState } from "react";
import { z } from "zod";
-import { Button } from "@/components/ui/button";
-import { Spinner } from "@/components/ui/spinner";
-import { MarkdownViewer } from "@/components/markdown-viewer";
+import {
+ openReportPanelAtom,
+ reportPanelAtom,
+} from "@/atoms/chat/report-panel.atom";
import { baseApiService } from "@/lib/apis/base-api.service";
-import { authenticatedFetch } from "@/lib/auth-utils";
/**
* Zod schemas for runtime validation
@@ -35,22 +33,12 @@ const GenerateReportResultSchema = z.object({
error: z.string().nullish(),
});
-const ReportContentResponseSchema = z.object({
+const ReportMetadataResponseSchema = z.object({
id: z.number(),
title: z.string(),
- content: z.string().nullish(),
report_metadata: z
.object({
- sections: z
- .array(
- z.object({
- level: z.number(),
- title: z.string(),
- })
- )
- .nullish(),
word_count: z.number().nullish(),
- char_count: z.number().nullish(),
section_count: z.number().nullish(),
})
.nullish(),
@@ -61,38 +49,70 @@ const ReportContentResponseSchema = z.object({
*/
type GenerateReportArgs = z.infer;
type GenerateReportResult = z.infer;
-type ReportContentResponse = z.infer;
/**
- * Loading state component shown while report is being generated
+ * Shimmer line used in the skeleton loading state.
+ * Each line has a staggered delay and random-ish width to mimic real paragraphs.
+ */
+function ShimmerLine({ width, delay }: { width: string; delay: string }) {
+ return (
+
+ );
+}
+
+/**
+ * Loading state component shown while report is being generated.
+ * Renders a card with a skeleton that looks like report content being written.
*/
function ReportGeneratingState({ topic }: { topic: string }) {
return (
-
-
-
-
-
+
+ {/* Header */}
+
+
+
{topic}
-
-
-
- Generating report. This may take a moment...
-
-
-
+
+ {/* Skeleton body – simulates paragraphs being written */}
+
+ {/* "Heading" */}
+
+
+ {/* Paragraph 1 */}
+
+
+
+
+
+
+
+ {/* "Heading 2" */}
+
+
+ {/* Paragraph 2 */}
+
+
+
+
+
+
+ {/* Bottom fade-out */}
+
+
);
}
@@ -121,9 +141,10 @@ function ReportErrorState({ title, error }: { title: string; error: string }) {
}
/**
- * Report viewer component that fetches and renders the full Markdown report
+ * Compact report card shown inline in the chat.
+ * Clicking it opens the report in the right-side panel (desktop) or Vaul drawer (mobile).
*/
-function ReportViewer({
+function ReportCard({
reportId,
title,
wordCount,
@@ -132,188 +153,87 @@ function ReportViewer({
title: string;
wordCount?: number;
}) {
- const [reportContent, setReportContent] = useState
(null);
+ const openPanel = useSetAtom(openReportPanelAtom);
+ const panelState = useAtomValue(reportPanelAtom);
+ const [metadata, setMetadata] = useState<{
+ title: string;
+ wordCount: number | null;
+ sectionCount: number | null;
+ }>({ title, wordCount: wordCount ?? null, sectionCount: null });
const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
- const [copied, setCopied] = useState(false);
- const [exporting, setExporting] = useState<"pdf" | "docx" | null>(null);
- // Fetch report content
+ // Fetch lightweight metadata (title + counts only, no content)
useEffect(() => {
- const fetchContent = async () => {
+ let cancelled = false;
+ const fetchMetadata = async () => {
setIsLoading(true);
- setError(null);
try {
const rawData = await baseApiService.get(
`/api/v1/reports/${reportId}/content`
);
- const parsed = ReportContentResponseSchema.safeParse(rawData);
+ if (cancelled) return;
+ const parsed = ReportMetadataResponseSchema.safeParse(rawData);
if (parsed.success) {
- setReportContent(parsed.data);
- } else {
- console.warn("Invalid report content response:", parsed.error.issues);
- setError("Invalid response format");
+ setMetadata({
+ title: parsed.data.title || title,
+ wordCount:
+ parsed.data.report_metadata?.word_count ?? wordCount ?? null,
+ sectionCount:
+ parsed.data.report_metadata?.section_count ?? null,
+ });
}
- } catch (err) {
- console.error("Error fetching report content:", err);
- setError(err instanceof Error ? err.message : "Failed to load report");
+ } catch {
+ // Silently fail — we already have the title and word count from the tool result
} finally {
- setIsLoading(false);
+ if (!cancelled) setIsLoading(false);
}
};
+ fetchMetadata();
+ return () => {
+ cancelled = true;
+ };
+ }, [reportId, title, wordCount]);
- fetchContent();
- }, [reportId]);
+ const isActive = panelState.isOpen && panelState.reportId === reportId;
- // Copy markdown content
- const handleCopy = useCallback(async () => {
- if (!reportContent?.content) return;
- try {
- await navigator.clipboard.writeText(reportContent.content);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- } catch (err) {
- console.error("Failed to copy:", err);
- }
- }, [reportContent?.content]);
-
- // Export report
- const handleExport = useCallback(
- async (format: "pdf" | "docx") => {
- setExporting(format);
- try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${reportId}/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 = `${title.replace(/[^a-zA-Z0-9 _-]/g, "_").trim().slice(0, 80) || "report"}.${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);
- }
- },
- [reportId, title]
- );
-
- if (isLoading) {
- return (
-
-
-
-
-
-
-
- {title}
-
-
-
- Loading report...
-
-
-
-
- );
- }
-
- if (error || !reportContent) {
- return ;
- }
-
- const displayWordCount =
- wordCount ?? reportContent.report_metadata?.word_count ?? null;
+ const handleOpen = () => {
+ openPanel({
+ reportId,
+ title: metadata.title,
+ wordCount: metadata.wordCount ?? undefined,
+ });
+ };
return (
-
- {/* Header */}
-
-
-
-
-
-
-
- {reportContent.title || title}
-
- {displayWordCount != null && (
-
- {displayWordCount.toLocaleString()} words
- {reportContent.report_metadata?.section_count
- ? ` · ${reportContent.report_metadata.section_count} sections`
- : ""}
-
- )}
-
+
+
);
}
@@ -375,7 +295,7 @@ export const GenerateReportToolUI = makeAssistantToolUI<
// Ready with report_id
if (result.status === "ready" && result.report_id) {
return (
-