feat: integrate report panel into chat interface

- Added a new ReportPanel component to display report details inline within the chat interface.
- Updated NewChatPage and PublicChatView to include the ReportPanel, enhancing user experience by allowing report viewing alongside chat interactions.
- Introduced report panel state management with Jotai atoms to control visibility and data handling.
- Refactored existing components to accommodate the new report panel layout and functionality.
This commit is contained in:
Anish Sarkar 2026-02-11 18:50:57 +05:30
parent acad8c6d2b
commit e4244829ae
6 changed files with 592 additions and 212 deletions

View file

@ -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) {
<DisplayImageToolUI />
<ScrapeWebpageToolUI />
<div className="flex h-screen flex-col pt-16">
<PublicThread footer={<PublicChatFooter shareToken={shareToken} />} />
<div className="flex h-screen pt-16 overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<PublicThread footer={<PublicChatFooter shareToken={shareToken} />} />
</div>
<ReportPanel />
</div>
</AssistantRuntimeProvider>
</main>

View file

@ -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<typeof ReportContentResponseSchema>;
/**
* Shimmer loading skeleton for report panel
*/
function ReportPanelSkeleton() {
return (
<div className="space-y-6 p-6">
{/* Title skeleton */}
<div className="h-6 w-3/4 rounded-md bg-muted/60 animate-pulse" />
{/* Paragraph 1 */}
<div className="space-y-2.5">
<div className="h-3 w-full rounded-md bg-muted/60 animate-pulse" />
<div className="h-3 w-[95%] rounded-md bg-muted/60 animate-pulse [animation-delay:100ms]" />
<div className="h-3 w-[88%] rounded-md bg-muted/60 animate-pulse [animation-delay:200ms]" />
<div className="h-3 w-[60%] rounded-md bg-muted/60 animate-pulse [animation-delay:300ms]" />
</div>
{/* Heading */}
<div className="h-5 w-2/5 rounded-md bg-muted/60 animate-pulse [animation-delay:400ms]" />
{/* Paragraph 2 */}
<div className="space-y-2.5">
<div className="h-3 w-full rounded-md bg-muted/60 animate-pulse [animation-delay:500ms]" />
<div className="h-3 w-[92%] rounded-md bg-muted/60 animate-pulse [animation-delay:600ms]" />
<div className="h-3 w-[97%] rounded-md bg-muted/60 animate-pulse [animation-delay:700ms]" />
</div>
{/* Heading */}
<div className="h-5 w-1/3 rounded-md bg-muted/60 animate-pulse [animation-delay:800ms]" />
{/* Paragraph 3 */}
<div className="space-y-2.5">
<div className="h-3 w-[90%] rounded-md bg-muted/60 animate-pulse [animation-delay:900ms]" />
<div className="h-3 w-full rounded-md bg-muted/60 animate-pulse [animation-delay:1000ms]" />
<div className="h-3 w-[75%] rounded-md bg-muted/60 animate-pulse [animation-delay:1100ms]" />
</div>
</div>
);
}
/**
* 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<ReportContentResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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<unknown>(
`/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 <ReportPanelSkeleton />;
}
if (error || !reportContent) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<FileTextIcon className="size-6 text-muted-foreground" />
</div>
<div>
<p className="font-medium text-foreground">Failed to load report</p>
<p className="text-sm text-muted-foreground mt-1">
{error || "An unknown error occurred"}
</p>
</div>
</div>
);
}
return (
<>
{/* Action bar */}
<div className="flex items-center gap-1.5 border-b bg-muted/20 px-4 py-2 shrink-0">
<div className="min-w-0 flex-1">
{displayWordCount != null && (
<p className="text-muted-foreground text-xs">
{displayWordCount.toLocaleString()} words
{reportContent.report_metadata?.section_count
? ` · ${reportContent.report_metadata.section_count} sections`
: ""}
</p>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-7 px-2 text-xs"
>
{copied ? (
<CheckIcon className="size-3.5 mr-1" />
) : (
<ClipboardIcon className="size-3.5 mr-1" />
)}
{copied ? "Copied" : "Copy MD"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleExport("pdf")}
disabled={exporting !== null}
className="h-7 px-2 text-xs"
>
{exporting === "pdf" ? (
<Loader2Icon className="size-3.5 mr-1 animate-spin" />
) : (
<DownloadIcon className="size-3.5 mr-1" />
)}
PDF
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleExport("docx")}
disabled={exporting !== null}
className="h-7 px-2 text-xs"
>
{exporting === "docx" ? (
<Loader2Icon className="size-3.5 mr-1 animate-spin" />
) : (
<DownloadIcon className="size-3.5 mr-1" />
)}
DOCX
</Button>
{onClose && (
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="size-7 shrink-0 ml-1"
>
<XIcon className="size-4" />
<span className="sr-only">Close report panel</span>
</Button>
)}
</div>
{/* Report content */}
<div className="flex-1 overflow-y-auto scrollbar-thin">
<div className="px-5 py-5">
<h1 className="text-xl font-bold mb-4">{displayTitle}</h1>
{reportContent.content ? (
<MarkdownViewer content={reportContent.content} />
) : (
<p className="text-muted-foreground italic">
No content available.
</p>
)}
</div>
</div>
</>
);
}
/**
* Desktop report panel renders as a right-side flex sibling
*/
function DesktopReportPanel() {
const panelState = useAtomValue(reportPanelAtom);
const closePanel = useSetAtom(closeReportPanelAtom);
const panelRef = useRef<HTMLDivElement>(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 (
<div
ref={panelRef}
className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-background animate-in slide-in-from-right-4 duration-300 ease-out"
>
<ReportPanelContent
reportId={panelState.reportId}
title={panelState.title || "Report"}
wordCount={panelState.wordCount}
onClose={closePanel}
/>
</div>
);
}
/**
* 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 (
<Drawer
open={panelState.isOpen}
onOpenChange={(open) => {
if (!open) closePanel();
}}
shouldScaleBackground={false}
>
<DrawerContent
className="h-[90vh] max-h-[90vh] z-80"
overlayClassName="z-80"
>
<DrawerHandle />
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
<ReportPanelContent
reportId={panelState.reportId}
title={panelState.title || "Report"}
wordCount={panelState.wordCount}
/>
</div>
</DrawerContent>
</Drawer>
);
}
/**
* 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 <DesktopReportPanel />;
}
return <MobileReportDrawer />;
}

View file

@ -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<typeof GenerateReportArgsSchema>;
type GenerateReportResult = z.infer<typeof GenerateReportResultSchema>;
type ReportContentResponse = z.infer<typeof ReportContentResponseSchema>;
/**
* 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 (
<div
className="h-2.5 rounded-md bg-muted/60"
style={{
width,
animation: `pulse 2s cubic-bezier(0.4,0,0.6,1) infinite`,
animationDelay: delay,
}}
/>
);
}
/**
* 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 (
<div className="my-4 overflow-hidden rounded-xl border border-primary/20 bg-gradient-to-br from-primary/5 to-primary/10 p-4 sm:p-6">
<div className="flex items-center gap-3 sm:gap-4">
<div className="relative shrink-0">
<div className="flex size-12 sm:size-16 items-center justify-center rounded-full bg-primary/20">
<FileTextIcon className="size-6 sm:size-8 text-primary" />
</div>
<div className="absolute inset-1 animate-ping rounded-full bg-primary/20" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground text-sm sm:text-lg leading-tight truncate">
<div className="my-4 overflow-hidden rounded-xl border bg-card">
{/* Header */}
<div className="flex items-center gap-2 sm:gap-3 border-b bg-muted/30 px-4 py-3 sm:px-6 sm:py-4">
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight truncate">
{topic}
</h3>
<div className="mt-1.5 sm:mt-2 flex items-center gap-1.5 sm:gap-2 text-muted-foreground">
<Spinner size="sm" className="size-3 sm:size-4" />
<span className="text-xs sm:text-sm">
Generating report. This may take a moment...
</span>
</div>
<div className="mt-2 sm:mt-3">
<div className="h-1 sm:h-1.5 w-full overflow-hidden rounded-full bg-primary/10">
<div className="h-full w-1/3 animate-pulse rounded-full bg-primary" />
</div>
<div className="mt-1 flex items-center gap-1.5 text-muted-foreground">
<Loader2Icon className="size-3 animate-spin" />
<span className="text-[11px] sm:text-xs">Writing report</span>
</div>
</div>
</div>
{/* Skeleton body simulates paragraphs being written */}
<div className="px-4 py-4 sm:px-6 sm:py-5 space-y-4 max-h-52 overflow-hidden relative">
{/* "Heading" */}
<ShimmerLine width="40%" delay="0ms" />
{/* Paragraph 1 */}
<div className="space-y-2">
<ShimmerLine width="100%" delay="100ms" />
<ShimmerLine width="92%" delay="150ms" />
<ShimmerLine width="97%" delay="200ms" />
<ShimmerLine width="60%" delay="250ms" />
</div>
{/* "Heading 2" */}
<ShimmerLine width="35%" delay="300ms" />
{/* Paragraph 2 */}
<div className="space-y-2">
<ShimmerLine width="95%" delay="350ms" />
<ShimmerLine width="100%" delay="400ms" />
<ShimmerLine width="88%" delay="450ms" />
</div>
{/* Bottom fade-out */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-card to-transparent" />
</div>
</div>
);
}
@ -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<ReportContentResponse | null>(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<string | null>(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<unknown>(
`/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 (
<div className="my-4 overflow-hidden rounded-xl border bg-muted/30 p-4 sm:p-6">
<div className="flex items-center gap-3 sm:gap-4">
<div className="flex size-12 sm:size-16 shrink-0 items-center justify-center rounded-full bg-primary/10">
<FileTextIcon className="size-6 sm:size-8 text-primary/50" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight">
{title}
</h3>
<div className="mt-1.5 sm:mt-2 flex items-center gap-1.5 sm:gap-2 text-muted-foreground">
<Spinner size="sm" className="size-3 sm:size-4" />
<span className="text-xs sm:text-sm">Loading report...</span>
</div>
</div>
</div>
</div>
);
}
if (error || !reportContent) {
return <ReportErrorState title={title} error={error || "Failed to load report"} />;
}
const displayWordCount =
wordCount ?? reportContent.report_metadata?.word_count ?? null;
const handleOpen = () => {
openPanel({
reportId,
title: metadata.title,
wordCount: metadata.wordCount ?? undefined,
});
};
return (
<div className="my-4 overflow-hidden rounded-xl border bg-card">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b bg-muted/30 px-4 py-3 sm:px-6 sm:py-4">
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<div className="flex size-8 sm:size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="size-4 sm:size-5 text-primary" />
</div>
<div className="min-w-0">
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight truncate">
{reportContent.title || title}
</h3>
{displayWordCount != null && (
<p className="text-muted-foreground text-[10px] sm:text-xs mt-0.5">
{displayWordCount.toLocaleString()} words
{reportContent.report_metadata?.section_count
? ` · ${reportContent.report_metadata.section_count} sections`
: ""}
</p>
)}
</div>
<div
className={`my-4 overflow-hidden rounded-xl border bg-card transition-colors ${isActive ? "ring-2 ring-primary/50" : ""}`}
>
<button
type="button"
onClick={handleOpen}
className="flex w-full items-center gap-2 sm:gap-3 bg-muted/30 px-4 py-3 sm:px-6 sm:py-4 text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none"
>
<div className="flex size-8 sm:size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="size-4 sm:size-5 text-primary" />
</div>
{/* Action buttons */}
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-7 sm:h-8 px-2 sm:px-3 text-xs"
>
{copied ? (
<CheckIcon className="size-3.5 mr-1" />
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight truncate">
{isLoading ? title : metadata.title}
</h3>
<p className="text-muted-foreground text-[10px] sm:text-xs mt-0.5">
{isLoading ? (
<span className="inline-block h-3 w-24 rounded bg-muted/60 animate-pulse" />
) : (
<ClipboardIcon className="size-3.5 mr-1" />
<>
{metadata.wordCount != null &&
`${metadata.wordCount.toLocaleString()} words`}
{metadata.sectionCount != null &&
` · ${metadata.sectionCount} sections`}
</>
)}
{copied ? "Copied" : "Copy MD"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleExport("pdf")}
disabled={exporting !== null}
className="h-7 sm:h-8 px-2 sm:px-3 text-xs"
>
{exporting === "pdf" ? (
<Loader2Icon className="size-3.5 mr-1 animate-spin" />
) : (
<DownloadIcon className="size-3.5 mr-1" />
)}
PDF
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleExport("docx")}
disabled={exporting !== null}
className="h-7 sm:h-8 px-2 sm:px-3 text-xs"
>
{exporting === "docx" ? (
<Loader2Icon className="size-3.5 mr-1 animate-spin" />
) : (
<DownloadIcon className="size-3.5 mr-1" />
)}
DOCX
</Button>
</p>
</div>
</div>
{/* Markdown content */}
<div className="px-4 py-4 sm:px-6 sm:py-5 overflow-x-auto">
{reportContent.content ? (
<MarkdownViewer content={reportContent.content} />
) : (
<p className="text-muted-foreground italic">No content available.</p>
)}
</div>
</button>
</div>
);
}
@ -375,7 +295,7 @@ export const GenerateReportToolUI = makeAssistantToolUI<
// Ready with report_id
if (result.status === "ready" && result.report_id) {
return (
<ReportViewer
<ReportCard
reportId={result.report_id}
title={result.title || topic}
wordCount={result.word_count ?? undefined}