diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 355fb2211..2faea0fca 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -39,6 +39,7 @@ import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory"; +import { ReportPanel } from "@/components/report-panel/report-panel"; import { Skeleton } from "@/components/ui/skeleton"; import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; import { useMessagesElectric } from "@/hooks/use-messages-electric"; @@ -1436,11 +1437,14 @@ export default function NewChatPage() { {/* Disabled for now */} -
- } - /> +
+
+ } + /> +
+
); diff --git a/surfsense_web/atoms/chat/current-thread.atom.ts b/surfsense_web/atoms/chat/current-thread.atom.ts index 5de11eb92..aee5bf7b4 100644 --- a/surfsense_web/atoms/chat/current-thread.atom.ts +++ b/surfsense_web/atoms/chat/current-thread.atom.ts @@ -1,5 +1,6 @@ import { atom } from "jotai"; import type { ChatVisibility } from "@/lib/chat/thread-persistence"; +import { reportPanelAtom, reportPanelOpenAtom } from "./report-panel.atom"; // TODO: Update `hasComments` to true when the first comment is created on a thread. // Currently it only updates on thread load. The gutter still works because @@ -39,6 +40,8 @@ export const showCommentsGutterAtom = atom((get) => { const thread = get(currentThreadAtom); // Hide gutter if comments are collapsed if (thread.commentsCollapsed) return false; + // Hide gutter if report panel is open (report panel takes the right side) + if (get(reportPanelOpenAtom)) return false; return ( thread.visibility === "SEARCH_SPACE" && (thread.hasComments || thread.addingCommentToMessageId !== null) @@ -59,6 +62,8 @@ export const setThreadVisibilityAtom = atom(null, (get, set, newVisibility: Chat export const resetCurrentThreadAtom = atom(null, (_, set) => { set(currentThreadAtom, initialState); + // Also close the report panel when resetting the thread + set(reportPanelAtom, { isOpen: false, reportId: null, title: null, wordCount: null }); }); /** Atom to read whether comments panel is collapsed */ diff --git a/surfsense_web/atoms/chat/report-panel.atom.ts b/surfsense_web/atoms/chat/report-panel.atom.ts new file mode 100644 index 000000000..6ed622be7 --- /dev/null +++ b/surfsense_web/atoms/chat/report-panel.atom.ts @@ -0,0 +1,48 @@ +import { atom } from "jotai"; + +interface ReportPanelState { + isOpen: boolean; + reportId: number | null; + title: string | null; + wordCount: number | null; +} + +const initialState: ReportPanelState = { + isOpen: false, + reportId: null, + title: null, + wordCount: null, +}; + +/** Core atom holding the report panel state */ +export const reportPanelAtom = atom(initialState); + +/** Derived read-only atom for checking if panel is open */ +export const reportPanelOpenAtom = atom((get) => get(reportPanelAtom).isOpen); + +/** Action atom to open the report panel with a specific report */ +export const openReportPanelAtom = atom( + null, + ( + _get, + set, + { + reportId, + title, + wordCount, + }: { reportId: number; title: string; wordCount?: number } + ) => { + set(reportPanelAtom, { + isOpen: true, + reportId, + title, + wordCount: wordCount ?? null, + }); + } +); + +/** Action atom to close the report panel */ +export const closeReportPanelAtom = atom(null, (_, set) => { + set(reportPanelAtom, initialState); +}); + diff --git a/surfsense_web/components/public-chat/public-chat-view.tsx b/surfsense_web/components/public-chat/public-chat-view.tsx index ad175128d..9573b5cad 100644 --- a/surfsense_web/components/public-chat/public-chat-view.tsx +++ b/surfsense_web/components/public-chat/public-chat-view.tsx @@ -7,6 +7,7 @@ import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; +import { ReportPanel } from "@/components/report-panel/report-panel"; import { Spinner } from "@/components/ui/spinner"; import { usePublicChat } from "@/hooks/use-public-chat"; import { usePublicChatRuntime } from "@/hooks/use-public-chat-runtime"; @@ -48,8 +49,11 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) { -
- } /> +
+
+ } /> +
+
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... - -
-
-
-
-
+
+ + Writing report…
+ + {/* 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` - : ""} -

- )} -
+
+ - - +

-
- - {/* Markdown content */} -
- {reportContent.content ? ( - - ) : ( -

No content available.

- )} -
+
); } @@ -375,7 +295,7 @@ export const GenerateReportToolUI = makeAssistantToolUI< // Ready with report_id if (result.status === "ready" && result.report_id) { return ( -