feat(web): add multi-format report export (Markdown, HTML, PDF, Word,… (#756)

* feat(web): add multi-format report export (Markdown, HTML, PDF, Word, Image)

* fix: correct import order for docx (lint error)

* fix(web): address Copilot review comments for multi-format export

- Add i18n support for dropdown menu items (en/zh)

- Add DOMPurify for HTML sanitization (XSS protection)

- Fix async handling for canvas.toBlob with Promise wrapper

- Add toast notifications for export errors

- Fix Tooltip + DropdownMenuTrigger nesting (accessibility)

- Ensure container cleanup in finally block

* fix(web): enhance markdown parsing for PDF and Word export

- Add list support (bullet and numbered) for PDF export
- Add parseInlineMarkdown helper for Word export to handle bold, italic, code, links
- Add list support for Word export (bullet and numbered)
- Address Copilot review comments from PR #756

* fix(web): address PR review feedback for multi-format export

- Extract PDF formatting magic numbers into PDF_CONSTANTS

- Add Tooltip wrapper for download dropdown button

- Reduce triggerDownload cleanup timeout from 1000ms to 100ms

- Use marked.Lexer.lexInline for robust markdown parsing

- Add console.warn for image export cleanup errors

- Add numbering config for Word document ordered lists

- Fix CSS class typo: px-5pb-20 -> px-5 pb-20

- Remove unreachable dead code in parseInlineMarkdown

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Jiahe Wu
2025-12-16 09:06:24 +08:00
committed by GitHub
parent 2a97170b6c
commit a4f64abd1f
5 changed files with 1083 additions and 113 deletions

View File

@@ -130,7 +130,13 @@
"generatePodcast": "Generate podcast",
"edit": "Edit",
"copy": "Copy",
"downloadReport": "Download report as markdown",
"downloadReport": "Download report",
"downloadMarkdown": "Markdown (.md)",
"downloadHTML": "HTML (.html)",
"downloadPDF": "PDF (.pdf)",
"downloadWord": "Word (.docx)",
"downloadImage": "Image (.png)",
"exportFailed": "Export failed, please try again",
"searchingFor": "Searching for",
"reading": "Reading",
"runningPythonCode": "Running Python code",

View File

@@ -130,7 +130,13 @@
"generatePodcast": "生成播客",
"edit": "编辑",
"copy": "复制",
"downloadReport": "下载报告为 Markdown",
"downloadReport": "下载报告",
"downloadMarkdown": "Markdown (.md)",
"downloadHTML": "HTML (.html)",
"downloadPDF": "PDF (.pdf)",
"downloadWord": "Word (.docx)",
"downloadImage": "图片 (.png)",
"exportFailed": "导出失败,请重试",
"searchingFor": "搜索",
"reading": "阅读中",
"runningPythonCode": "运行 Python 代码",

View File

@@ -49,21 +49,28 @@
"@tiptap/extension-table-row": "^2.11.7",
"@tiptap/extension-text": "^2.12.0",
"@tiptap/react": "^2.11.7",
"@types/dompurify": "^3.2.0",
"@types/js-cookie": "^3.0.6",
"@xyflow/react": "^12.6.0",
"best-effort-json-parser": "^1.1.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"docx": "^9.5.1",
"dompurify": "^3.3.1",
"file-saver": "^2.0.5",
"framer-motion": "^12.6.5",
"hast": "^1.0.0",
"highlight.js": "^11.11.1",
"html2canvas": "^1.4.1",
"immer": "^10.1.1",
"js-cookie": "^3.0.5",
"jspdf": "^3.0.4",
"katex": "^0.16.21",
"lowlight": "^3.3.0",
"lru-cache": "^11.1.0",
"lucide-react": "^0.487.0",
"marked": "^17.0.1",
"motion": "^12.7.4",
"nanoid": "^5.1.5",
"next": "^15.4.10",
@@ -94,6 +101,7 @@
"@tailwindcss/postcss": "^4.0.15",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/file-saver": "^2.0.7",
"@types/hast": "^3.0.4",
"@types/jest": "^30.0.0",
"@types/node": "^20.14.10",

525
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,46 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { Check, Copy, Headphones, Pencil, Undo2, X, Download } from "lucide-react";
import {
Document,
Packer,
Paragraph,
TextRun,
HeadingLevel,
ExternalHyperlink,
} from "docx";
import DOMPurify from "dompurify";
import { saveAs } from "file-saver";
import html2canvas from "html2canvas";
import { jsPDF } from "jspdf";
import {
Check,
Copy,
Headphones,
Pencil,
Undo2,
X,
Download,
FileText,
FileCode,
FileImage,
FileType,
} from "lucide-react";
import { marked } from "marked";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { ScrollContainer } from "~/components/deer-flow/scroll-container";
import { Tooltip } from "~/components/deer-flow/tooltip";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { useReplay } from "~/core/replay";
import { closeResearch, listenToPodcast, useStore } from "~/core/store";
@@ -50,6 +82,7 @@ export function ResearchBlock({
}, [researchId]);
const [editing, setEditing] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
if (!reportId) {
@@ -66,38 +99,534 @@ export function ResearchBlock({
}, 1000);
}, [reportId]);
// Download report as markdown
const handleDownload = useCallback(() => {
if (!reportId) {
return;
}
const report = useStore.getState().messages.get(reportId);
if (!report) {
return;
}
// Helper function to generate timestamp for filenames
const getTimestamp = useCallback(() => {
const now = new Date();
const pad = (n: number) => n.toString().padStart(2, '0');
const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
const filename = `research-report-${timestamp}.md`;
const blob = new Blob([report.content], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
try {
if (a.parentNode) {
a.parentNode.removeChild(a);
}
} finally {
URL.revokeObjectURL(url);
}
}, 0);
}, [reportId]);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
}, []);
// Helper function to trigger file download
const triggerDownload = useCallback(
(content: string, filename: string, mimeType: string) => {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
try {
if (a.parentNode) {
a.parentNode.removeChild(a);
}
} finally {
URL.revokeObjectURL(url);
}
}, 100);
},
[],
);
// Download report as Markdown
const handleDownloadMarkdown = useCallback(() => {
if (!reportId) return;
const report = useStore.getState().messages.get(reportId);
if (!report) return;
triggerDownload(
report.content,
`research-report-${getTimestamp()}.md`,
"text/markdown",
);
}, [reportId, getTimestamp, triggerDownload]);
// Download report as HTML
const handleDownloadHTML = useCallback(() => {
if (!reportId) return;
const report = useStore.getState().messages.get(reportId);
if (!report) return;
const rawHtml = marked(report.content) as string;
const htmlContent = DOMPurify.sanitize(rawHtml);
const fullHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Research Report</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; }
h1, h2, h3 { color: #333; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 4px; }
pre { background: #f4f4f4; padding: 16px; border-radius: 8px; overflow-x: auto; }
blockquote { border-left: 4px solid #ddd; margin: 0; padding-left: 16px; color: #666; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #f4f4f4; }
</style>
</head>
<body>
${htmlContent}
</body>
</html>`;
triggerDownload(
fullHTML,
`research-report-${getTimestamp()}.html`,
"text/html",
);
}, [reportId, getTimestamp, triggerDownload]);
// Download report as PDF (text-based, no html2canvas)
const handleDownloadPDF = useCallback(async () => {
if (!reportId || isDownloading) return;
const report = useStore.getState().messages.get(reportId);
if (!report) return;
setIsDownloading(true);
try {
const pdf = new jsPDF("p", "mm", "a4");
const pageWidth = 210;
const pageHeight = 297;
const margin = 20;
const maxWidth = pageWidth - 2 * margin;
let y = margin;
// PDF formatting constants for maintainability
const PDF_CONSTANTS = {
headings: {
h1: { fontSize: 20, lineHeight: 9, spacing: 6 },
h2: { fontSize: 16, lineHeight: 7, spacing: 5 },
h3: { fontSize: 14, lineHeight: 6, spacing: 4 },
},
text: { fontSize: 11, normalHeight: 5, paragraphSpacing: 2 },
list: { bullet: "• ", indentLevel: 2 },
emptyLine: { height: 4 },
};
const lines = report.content.split("\n");
for (const line of lines) {
// Handle headings
if (line.startsWith("### ")) {
const h3 = PDF_CONSTANTS.headings.h3;
pdf.setFontSize(h3.fontSize);
pdf.setFont("helvetica", "bold");
const text = line.substring(4);
const splitText = pdf.splitTextToSize(text, maxWidth);
if (y + 10 > pageHeight - margin) {
pdf.addPage();
y = margin;
}
pdf.text(splitText, margin, y);
y += splitText.length * h3.lineHeight + h3.spacing;
} else if (line.startsWith("## ")) {
const h2 = PDF_CONSTANTS.headings.h2;
pdf.setFontSize(h2.fontSize);
pdf.setFont("helvetica", "bold");
const text = line.substring(3);
const splitText = pdf.splitTextToSize(text, maxWidth);
if (y + 12 > pageHeight - margin) {
pdf.addPage();
y = margin;
}
pdf.text(splitText, margin, y);
y += splitText.length * h2.lineHeight + h2.spacing;
} else if (line.startsWith("# ")) {
const h1 = PDF_CONSTANTS.headings.h1;
pdf.setFontSize(h1.fontSize);
pdf.setFont("helvetica", "bold");
const text = line.substring(2);
const splitText = pdf.splitTextToSize(text, maxWidth);
if (y + 14 > pageHeight - margin) {
pdf.addPage();
y = margin;
}
pdf.text(splitText, margin, y);
y += splitText.length * h1.lineHeight + h1.spacing;
} else if (line.startsWith("- ") || line.startsWith("* ")) {
// Unordered list item
const textConfig = PDF_CONSTANTS.text;
pdf.setFontSize(textConfig.fontSize);
pdf.setFont("helvetica", "normal");
const cleanText = line
.substring(2)
.replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/\*(.*?)\*/g, "$1")
.replace(/`(.*?)`/g, "$1")
.replace(/\[(.*?)\]\(.*?\)/g, "$1");
const bulletText = `${cleanText}`;
const splitText = pdf.splitTextToSize(bulletText, maxWidth - 5);
if (
y + splitText.length * textConfig.normalHeight >
pageHeight - margin
) {
pdf.addPage();
y = margin;
}
pdf.text(splitText, margin + PDF_CONSTANTS.list.indentLevel, y);
y +=
splitText.length * textConfig.normalHeight +
PDF_CONSTANTS.text.paragraphSpacing;
} else if (/^\d+\.\s/.test(line)) {
// Ordered list item
const textConfig = PDF_CONSTANTS.text;
pdf.setFontSize(textConfig.fontSize);
pdf.setFont("helvetica", "normal");
const match = /^(\d+)\.\s(.*)$/.exec(line);
if (match?.[1] && match[2]) {
const cleanText = match[2]
.replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/\*(.*?)\*/g, "$1")
.replace(/`(.*?)`/g, "$1")
.replace(/\[(.*?)\]\(.*?\)/g, "$1");
const numberedText = `${match[1]}. ${cleanText}`;
const splitText = pdf.splitTextToSize(numberedText, maxWidth - 5);
if (
y + splitText.length * textConfig.normalHeight >
pageHeight - margin
) {
pdf.addPage();
y = margin;
}
pdf.text(splitText, margin + PDF_CONSTANTS.list.indentLevel, y);
y +=
splitText.length * textConfig.normalHeight +
PDF_CONSTANTS.text.paragraphSpacing;
}
} else if (line.trim()) {
// Normal text
const textConfig = PDF_CONSTANTS.text;
pdf.setFontSize(textConfig.fontSize);
pdf.setFont("helvetica", "normal");
// Remove markdown formatting
const cleanText = line
.replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/\*(.*?)\*/g, "$1")
.replace(/`(.*?)`/g, "$1")
.replace(/\[(.*?)\]\(.*?\)/g, "$1");
const splitText = pdf.splitTextToSize(cleanText, maxWidth);
if (
y + splitText.length * textConfig.normalHeight >
pageHeight - margin
) {
pdf.addPage();
y = margin;
}
pdf.text(splitText, margin, y);
y +=
splitText.length * textConfig.normalHeight +
PDF_CONSTANTS.text.paragraphSpacing;
} else {
// Empty line
y += PDF_CONSTANTS.emptyLine.height;
}
// Check page overflow
if (y > pageHeight - margin) {
pdf.addPage();
y = margin;
}
}
pdf.save(`research-report-${getTimestamp()}.pdf`);
} catch (error) {
console.error("Failed to generate PDF:", error);
toast.error(t("exportFailed"));
} finally {
setIsDownloading(false);
}
}, [reportId, getTimestamp, isDownloading, t]);
// Helper function to parse inline markdown formatting for Word export
const parseInlineMarkdown = useCallback(
(text: string): (TextRun | ExternalHyperlink)[] => {
// Process text recursively using marked's inline lexer
const runs: (TextRun | ExternalHyperlink)[] = [];
try {
// Use marked's Lexer to safely parse inline markdown
const tokens = marked.Lexer.lexInline(text);
interface MarkedToken {
type: string;
text?: string;
tokens?: MarkedToken[];
href?: string;
}
const processTokens = (tokens: MarkedToken[]): void => {
for (const token of tokens) {
if (token.type === "text") {
// Regular text
if (token.text) {
runs.push(new TextRun(token.text));
}
} else if (token.type === "strong") {
// Bold text - may contain nested tokens
if (token.tokens && token.tokens.length > 0) {
// Process nested tokens and mark them as bold
const nestedRuns: TextRun[] = [];
for (const nestedToken of token.tokens) {
if (nestedToken.type === "text") {
nestedRuns.push(
new TextRun({ text: nestedToken.text, bold: true }),
);
} else if (nestedToken.type === "em") {
// Bold + italic nested tokens
nestedRuns.push(
new TextRun({
text: nestedToken.text,
bold: true,
italics: true,
}),
);
}
}
runs.push(...nestedRuns);
} else {
runs.push(new TextRun({ text: token.text, bold: true }));
}
} else if (token.type === "em") {
// Italic text
runs.push(
new TextRun({
text: token.text ?? token.tokens?.[0]?.text,
italics: true,
}),
);
} else if (token.type === "codespan") {
// Inline code
if (token.text) {
runs.push(
new TextRun({ text: token.text, font: "Courier New" }),
);
}
} else if (token.type === "link") {
// Link - use the link text or fallback to URL
const linkText = token.text ?? token.href ?? "";
const linkUrl = token.href ?? "";
if (linkUrl) {
runs.push(
new ExternalHyperlink({
children: [
new TextRun({ text: linkText, style: "Hyperlink" }),
],
link: linkUrl,
}),
);
} else {
// Fallback to plain text if no URL
runs.push(new TextRun(linkText));
}
} else if (token.type === "space") {
// Preserve spaces
runs.push(new TextRun(" "));
}
}
};
processTokens(tokens);
} catch (error) {
// Fallback to simple text parsing if marked fails
console.warn("Marked parsing failed, using fallback:", error);
// Pattern to match: bold (**text**), italic (*text*), inline code (`text`), links [text](url)
const pattern =
/(\*\*(.+?)\*\*)|(\*(.+?)\*)|(`(.+?)`)|\[(.+?)\]\((.+?)\)/g;
let lastIndex = 0;
let match;
while ((match = pattern.exec(text)) !== null) {
// Add plain text before the match
if (match.index > lastIndex) {
runs.push(new TextRun(text.slice(lastIndex, match.index)));
}
if (match[1]) {
// Bold: **text**
const boldText = match[2] ?? "";
runs.push(new TextRun({ text: boldText, bold: true }));
} else if (match[3]) {
// Italic: *text*
const italicText = match[4] ?? "";
runs.push(new TextRun({ text: italicText, italics: true }));
} else if (match[5]) {
// Inline code: `text`
const codeText = match[6] ?? "";
runs.push(new TextRun({ text: codeText, font: "Courier New" }));
} else if (match[7] && match[8]) {
// Link: [text](url)
runs.push(
new ExternalHyperlink({
children: [
new TextRun({ text: match[7] ?? "", style: "Hyperlink" }),
],
link: match[8],
}),
);
}
lastIndex = pattern.lastIndex;
}
// Add remaining plain text
if (lastIndex < text.length) {
runs.push(new TextRun(text.slice(lastIndex)));
}
}
return runs.length > 0 ? runs : [new TextRun(text)];
},
[],
);
// Download report as Word document
const handleDownloadWord = useCallback(async () => {
if (!reportId || isDownloading) return;
const report = useStore.getState().messages.get(reportId);
if (!report) return;
setIsDownloading(true);
try {
// Parse markdown content into paragraphs
const lines = report.content.split("\n");
const children: Paragraph[] = [];
for (const line of lines) {
if (line.startsWith("# ")) {
children.push(
new Paragraph({
children: parseInlineMarkdown(line.substring(2)),
heading: HeadingLevel.HEADING_1,
}),
);
} else if (line.startsWith("## ")) {
children.push(
new Paragraph({
children: parseInlineMarkdown(line.substring(3)),
heading: HeadingLevel.HEADING_2,
}),
);
} else if (line.startsWith("### ")) {
children.push(
new Paragraph({
children: parseInlineMarkdown(line.substring(4)),
heading: HeadingLevel.HEADING_3,
}),
);
} else if (line.startsWith("- ") || line.startsWith("* ")) {
// Unordered list item
children.push(
new Paragraph({
children: parseInlineMarkdown(line.substring(2)),
bullet: { level: 0 },
}),
);
} else if (/^\d+\.\s/.test(line)) {
// Ordered list item
const text = line.replace(/^\d+\.\s/, "");
children.push(
new Paragraph({
children: parseInlineMarkdown(text),
numbering: { reference: "default-numbering", level: 0 },
}),
);
} else if (line.trim()) {
children.push(
new Paragraph({
children: parseInlineMarkdown(line),
}),
);
} else {
children.push(new Paragraph({ text: "" }));
}
}
const doc = new Document({
numbering: {
config: [
{
reference: "default-numbering",
levels: [
{
level: 0,
format: "decimal",
text: "%1.",
alignment: "start",
},
],
},
],
},
sections: [{ children }],
});
const blob = await Packer.toBlob(doc);
saveAs(blob, `research-report-${getTimestamp()}.docx`);
} catch (error) {
console.error("Failed to generate Word document:", error);
toast.error(t("exportFailed"));
} finally {
setIsDownloading(false);
}
}, [reportId, getTimestamp, isDownloading, t, parseInlineMarkdown]);
// Download report as Image
const handleDownloadImage = useCallback(async () => {
if (!reportId || isDownloading) return;
const report = useStore.getState().messages.get(reportId);
if (!report) return;
setIsDownloading(true);
let container: HTMLDivElement | null = null;
try {
// Create a temporary container with simple styles to avoid color parsing issues
container = document.createElement("div");
container.style.cssText =
"position: absolute; left: -9999px; top: 0; width: 800px; padding: 40px; font-family: Arial, sans-serif; line-height: 1.6; background-color: #ffffff; color: #000000;";
const styleTag =
"<style>* { color: #000000; } h1,h2,h3,h4,h5,h6 { color: #333333; } a { color: #0066cc; } code { background-color: #f5f5f5; padding: 2px 4px; } pre { background-color: #f5f5f5; padding: 12px; }</style>";
const rawHtml = marked(report.content) as string;
const sanitizedHtml = DOMPurify.sanitize(rawHtml);
container.innerHTML = styleTag + sanitizedHtml;
document.body.appendChild(container);
const canvas = await html2canvas(container, {
scale: 2,
useCORS: true,
logging: false,
backgroundColor: "#ffffff",
});
// Promisify toBlob for proper async handling
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob((b) => resolve(b), "image/png");
});
if (blob) {
saveAs(blob, `research-report-${getTimestamp()}.png`);
}
} catch (error) {
console.error("Failed to generate image:", error);
toast.error(t("exportFailed"));
} finally {
// Ensure container is always removed
try {
container?.parentNode?.removeChild(container);
} catch (error) {
// Log cleanup errors for better debugging (not just in development)
console.warn(
"Failed to remove temporary container during image export cleanup:",
error,
);
// Don't throw - cleanup failures are expected and harmless
}
setIsDownloading(false);
}
}, [reportId, getTimestamp, isDownloading, t]);
const handleEdit = useCallback(() => {
setEditing((editing) => !editing);
}, []);
@@ -147,16 +676,50 @@ export function ResearchBlock({
{copied ? <Check /> : <Copy />}
</Button>
</Tooltip>
<Tooltip title={t("downloadReport")}>
<Button
className="text-gray-400"
size="icon"
variant="ghost"
onClick={handleDownload}
>
<Download />
</Button>
</Tooltip>
<DropdownMenu>
<Tooltip title={t("downloadReport")}>
<DropdownMenuTrigger asChild>
<Button
className="text-gray-400"
size="icon"
variant="ghost"
>
<Download />
</Button>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleDownloadMarkdown}>
<FileText className="mr-2 h-4 w-4" />
{t("downloadMarkdown")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDownloadHTML}>
<FileCode className="mr-2 h-4 w-4" />
{t("downloadHTML")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDownloadPDF}
disabled={isDownloading}
>
<FileType className="mr-2 h-4 w-4" />
{t("downloadPDF")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDownloadWord}
disabled={isDownloading}
>
<FileText className="mr-2 h-4 w-4" />
{t("downloadWord")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDownloadImage}
disabled={isDownloading}
>
<FileImage className="mr-2 h-4 w-4" />
{t("downloadImage")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
<Tooltip title={t("close")}>
@@ -198,7 +761,7 @@ export function ResearchBlock({
hidden={activeTab !== "report"}
>
<ScrollContainer
className="px-5pb-20 h-full"
className="h-full px-5 pb-20"
scrollShadowColor="var(--card)"
autoScrollToBottom={!hasReport || reportStreaming}
>