refactor: enhance report generation UI with improved loading and error states, and update report content handling

This commit is contained in:
Anish Sarkar 2026-03-21 22:02:13 +05:30
parent 19bd0c48e8
commit b6be92e394

View file

@ -2,11 +2,12 @@
import { makeAssistantToolUI } from "@assistant-ui/react"; import { makeAssistantToolUI } from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { Dot, FileTextIcon } from "lucide-react"; import { Dot } from "lucide-react";
import { useParams, usePathname } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { z } from "zod"; import { z } from "zod";
import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { baseApiService } from "@/lib/apis/base-api.service"; import { baseApiService } from "@/lib/apis/base-api.service";
@ -31,9 +32,10 @@ const GenerateReportResultSchema = z.object({
error: z.string().nullish(), error: z.string().nullish(),
}); });
const ReportMetadataResponseSchema = z.object({ const ReportContentResponseSchema = z.object({
id: z.number(), id: z.number(),
title: z.string(), title: z.string(),
content: z.string().nullish(),
report_metadata: z report_metadata: z
.object({ .object({
status: z.enum(["ready", "failed"]).nullish(), status: z.enum(["ready", "failed"]).nullish(),
@ -59,53 +61,59 @@ const ReportMetadataResponseSchema = z.object({
type GenerateReportArgs = z.infer<typeof GenerateReportArgsSchema>; type GenerateReportArgs = z.infer<typeof GenerateReportArgsSchema>;
type GenerateReportResult = z.infer<typeof GenerateReportResultSchema>; type GenerateReportResult = z.infer<typeof GenerateReportResultSchema>;
/** function ContentSkeleton() {
* Loading state component shown while report is being generated. return (
* Matches the compact card layout of the completed ReportCard. <div className="space-y-2">
*/ <div className="h-3 w-full rounded bg-muted/60 animate-pulse" />
<div className="h-3 w-[92%] rounded bg-muted/60 animate-pulse [animation-delay:100ms]" />
<div className="h-3 w-[75%] rounded bg-muted/60 animate-pulse [animation-delay:200ms]" />
</div>
);
}
function ReportGeneratingState({ topic }: { topic: string }) { function ReportGeneratingState({ topic }: { topic: string }) {
return ( return (
<div className="my-4 overflow-hidden rounded-xl border bg-card"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="flex w-full items-center gap-2 sm:gap-3 bg-muted/30 px-4 py-5 sm:px-6 sm:py-6"> <div className="px-5 pt-5 pb-4">
<div className="flex size-8 sm:size-12 shrink-0 items-center justify-center rounded-lg bg-primary/10"> <p className="text-sm font-semibold text-foreground line-clamp-2">{topic}</p>
<FileTextIcon className="size-4 sm:size-6 text-primary" /> <TextShimmerLoader text="Putting things together" size="sm" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="mx-5 h-px bg-border/50" />
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight line-clamp-2"> <div className="px-5 pt-3 pb-4">
{topic} <ContentSkeleton />
</h3>
<TextShimmerLoader text="Putting things together" size="sm" />
</div>
</div> </div>
</div> </div>
); );
} }
/**
* Error state component shown when report generation fails
*/
function ReportErrorState({ title, error }: { title: string; error: string }) { function ReportErrorState({ title, error }: { title: string; error: string }) {
return ( return (
<div className="my-4 overflow-hidden rounded-xl border bg-card"> <div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="flex items-center gap-2 sm:gap-3 bg-muted/30 px-4 py-5 sm:px-6 sm:py-6"> <div className="px-5 pt-5 pb-4">
<div className="flex size-8 sm:size-12 shrink-0 items-center justify-center rounded-lg bg-muted/60"> <p className="text-sm font-semibold text-destructive">Report Generation Failed</p>
<FileTextIcon className="size-4 sm:size-6 text-muted-foreground" /> </div>
</div> <div className="mx-5 h-px bg-border/50" />
<div className="min-w-0 flex-1"> <div className="px-5 py-4">
<h3 className="font-semibold text-muted-foreground text-sm sm:text-base leading-tight line-clamp-2"> <p className="text-sm font-medium text-foreground line-clamp-2">{title}</p>
{title} <p className="text-sm text-muted-foreground mt-1">{error}</p>
</h3> </div>
<p className="text-muted-foreground/60 text-[11px] sm:text-xs mt-0.5 truncate">{error}</p> </div>
</div> );
}
function ReportCancelledState() {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-muted-foreground">Report Cancelled</p>
<p className="text-xs text-muted-foreground mt-0.5">
Report generation was cancelled
</p>
</div> </div>
</div> </div>
); );
} }
/**
* Compact report card shown inline in the chat.
* Clicking it opens the report in the right-side panel (desktop) or Vaul drawer (mobile).
*/
function ReportCard({ function ReportCard({
reportId, reportId,
title, title,
@ -116,9 +124,7 @@ function ReportCard({
reportId: number; reportId: number;
title: string; title: string;
wordCount?: number; wordCount?: number;
/** When set, uses public endpoint for fetching report data */
shareToken?: string | null; shareToken?: string | null;
/** When true, auto-opens the report panel on desktop after metadata loads */
autoOpen?: boolean; autoOpen?: boolean;
}) { }) {
const openPanel = useSetAtom(openReportPanelAtom); const openPanel = useSetAtom(openReportPanelAtom);
@ -129,14 +135,14 @@ function ReportCard({
title: string; title: string;
wordCount: number | null; wordCount: number | null;
versionLabel: string | null; versionLabel: string | null;
}>({ title, wordCount: wordCount ?? null, versionLabel: null }); content: string | null;
}>({ title, wordCount: wordCount ?? null, versionLabel: null, content: null });
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Fetch lightweight metadata (title + counts + version info)
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const fetchMetadata = async () => { const fetchData = async () => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
@ -145,13 +151,11 @@ function ReportCard({
: `/api/v1/reports/${reportId}/content`; : `/api/v1/reports/${reportId}/content`;
const rawData = await baseApiService.get<unknown>(url); const rawData = await baseApiService.get<unknown>(url);
if (cancelled) return; if (cancelled) return;
const parsed = ReportMetadataResponseSchema.safeParse(rawData); const parsed = ReportContentResponseSchema.safeParse(rawData);
if (parsed.success) { if (parsed.success) {
// Check if report was marked as failed in metadata
if (parsed.data.report_metadata?.status === "failed") { if (parsed.data.report_metadata?.status === "failed") {
setError(parsed.data.report_metadata?.error_message || "Report generation failed"); setError(parsed.data.report_metadata?.error_message || "Report generation failed");
} else { } else {
// Determine version label from versions array
let versionLabel: string | null = null; let versionLabel: string | null = null;
const versions = parsed.data.versions; const versions = parsed.data.versions;
if (versions && versions.length > 1) { if (versions && versions.length > 1) {
@ -162,7 +166,12 @@ function ReportCard({
} }
const resolvedTitle = parsed.data.title || title; const resolvedTitle = parsed.data.title || title;
const resolvedWordCount = parsed.data.report_metadata?.word_count ?? wordCount ?? null; const resolvedWordCount = parsed.data.report_metadata?.word_count ?? wordCount ?? null;
setMetadata({ title: resolvedTitle, wordCount: resolvedWordCount, versionLabel }); setMetadata({
title: resolvedTitle,
wordCount: resolvedWordCount,
versionLabel,
content: parsed.data.content ?? null,
});
if (autoOpen && isDesktop && !autoOpenedRef.current) { if (autoOpen && isDesktop && !autoOpenedRef.current) {
autoOpenedRef.current = true; autoOpenedRef.current = true;
@ -181,13 +190,12 @@ function ReportCard({
if (!cancelled) setIsLoading(false); if (!cancelled) setIsLoading(false);
} }
}; };
fetchMetadata(); fetchData();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [reportId, title, wordCount, shareToken, autoOpen, isDesktop, openPanel]); }, [reportId, title, wordCount, shareToken, autoOpen, isDesktop, openPanel]);
// Show non-clickable error card for any error (failed status, not found, etc.)
if (!isLoading && error) { if (!isLoading && error) {
return <ReportErrorState title={title} error={error} />; return <ReportErrorState title={title} error={error} />;
} }
@ -205,26 +213,24 @@ function ReportCard({
return ( return (
<div <div
className={`my-4 overflow-hidden rounded-xl border bg-card transition-colors ${isActive ? "ring-1 ring-primary/50" : ""}`} className={`my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300 ${isActive ? "ring-1 ring-primary/50" : ""}`}
> >
<button <button
type="button" type="button"
onClick={handleOpen} onClick={handleOpen}
className="flex w-full items-center gap-2 sm:gap-3 bg-muted/30 px-4 py-5 sm:px-6 sm:py-6 text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none" className="w-full text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none"
> >
<div className="flex size-8 sm:size-12 shrink-0 items-center justify-center rounded-lg bg-primary/10"> <div className="px-5 pt-5 pb-4 select-none">
<FileTextIcon className="size-4 sm:size-6 text-primary" /> <p className="text-sm font-semibold text-foreground line-clamp-2">
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight line-clamp-2">
{isLoading ? title : metadata.title} {isLoading ? title : metadata.title}
</h3> </p>
<p className="text-muted-foreground text-[10px] sm:text-xs mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{isLoading ? ( {isLoading ? (
<span className="inline-block h-3 w-24 rounded bg-muted/60 animate-pulse" /> <span className="inline-block h-3 w-24 rounded bg-muted/60 animate-pulse" />
) : ( ) : (
<> <>
{metadata.wordCount != null && `${metadata.wordCount.toLocaleString()} words`} {metadata.wordCount != null &&
`${metadata.wordCount.toLocaleString()} words`}
{metadata.wordCount != null && metadata.versionLabel && ( {metadata.wordCount != null && metadata.versionLabel && (
<Dot className="inline size-4" /> <Dot className="inline size-4" />
)} )}
@ -233,19 +239,41 @@ function ReportCard({
)} )}
</p> </p>
</div> </div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 pt-3 pb-4">
{isLoading ? (
<ContentSkeleton />
) : metadata.content ? (
<div
className="max-h-[7rem] overflow-hidden [&_*]:!text-[24px]"
style={{
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 50%, transparent 100%)",
}}
>
<PlateEditor
markdown={metadata.content}
readOnly
preset="readonly"
editorVariant="none"
className="h-auto [&_[data-slate-editor]]:!min-h-0 [&_[data-slate-editor]>*:first-child]:!mt-0"
/>
</div>
) : (
<p className="text-sm text-muted-foreground italic">No content available</p>
)}
</div>
</button> </button>
</div> </div>
); );
} }
/** /**
* Generate Report Tool UI Component * Generate Report Tool UI renders custom UI inline in chat
*
* This component is registered with assistant-ui to render custom UI
* when the generate_report tool is called by the agent. * when the generate_report tool is called by the agent.
*
* Unlike podcast (which uses polling), the report is generated inline
* and the result contains status: "ready" immediately.
*/ */
export const GenerateReportToolUI = makeAssistantToolUI<GenerateReportArgs, GenerateReportResult>({ export const GenerateReportToolUI = makeAssistantToolUI<GenerateReportArgs, GenerateReportResult>({
toolName: "generate_report", toolName: "generate_report",
@ -257,29 +285,18 @@ export const GenerateReportToolUI = makeAssistantToolUI<GenerateReportArgs, Gene
const topic = args.topic || "Report"; const topic = args.topic || "Report";
// Track whether we witnessed the generation (running state).
// If we mount directly with a result, this stays false → it's a revisit.
const sawRunningRef = useRef(false); const sawRunningRef = useRef(false);
if (status.type === "running" || status.type === "requires-action") { if (status.type === "running" || status.type === "requires-action") {
sawRunningRef.current = true; sawRunningRef.current = true;
} }
// Loading state - tool is still running (LLM generating report)
if (status.type === "running" || status.type === "requires-action") { if (status.type === "running" || status.type === "requires-action") {
return <ReportGeneratingState topic={topic} />; return <ReportGeneratingState topic={topic} />;
} }
// Incomplete/cancelled state
if (status.type === "incomplete") { if (status.type === "incomplete") {
if (status.reason === "cancelled") { if (status.reason === "cancelled") {
return ( return <ReportCancelledState />;
<div className="my-4 rounded-xl border border-muted p-3 sm:p-4 text-muted-foreground">
<p className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm">
<FileTextIcon className="size-3.5 sm:size-4" />
<span className="line-through">Report generation cancelled</span>
</p>
</div>
);
} }
if (status.reason === "error") { if (status.reason === "error") {
return ( return (
@ -291,12 +308,10 @@ export const GenerateReportToolUI = makeAssistantToolUI<GenerateReportArgs, Gene
} }
} }
// No result yet
if (!result) { if (!result) {
return <ReportGeneratingState topic={topic} />; return <ReportGeneratingState topic={topic} />;
} }
// Failed result
if (result.status === "failed") { if (result.status === "failed") {
return ( return (
<ReportErrorState <ReportErrorState
@ -306,7 +321,6 @@ export const GenerateReportToolUI = makeAssistantToolUI<GenerateReportArgs, Gene
); );
} }
// Ready with report_id
if (result.status === "ready" && result.report_id) { if (result.status === "ready" && result.report_id) {
return ( return (
<ReportCard <ReportCard
@ -319,7 +333,6 @@ export const GenerateReportToolUI = makeAssistantToolUI<GenerateReportArgs, Gene
); );
} }
// Fallback - missing required data
return <ReportErrorState title={topic} error="Missing report ID" />; return <ReportErrorState title={topic} error="Missing report ID" />;
}, },
}); });