diff --git a/surfsense_web/components/documents/DocumentsFilters.tsx b/surfsense_web/components/documents/DocumentsFilters.tsx index f03684631..a2ebe83b9 100644 --- a/surfsense_web/components/documents/DocumentsFilters.tsx +++ b/surfsense_web/components/documents/DocumentsFilters.tsx @@ -84,7 +84,7 @@ export function DocumentsFilters({ { e.preventDefault(); onCreateFolder(); @@ -104,11 +104,11 @@ export function DocumentsFilters({ value="ai-sort" disabled={aiSortBusy} className={cn( - "h-9 w-9 shrink-0 border-sidebar-border bg-sidebar", + "h-9 w-9 shrink-0 border bg-muted/50 transition-colors", "disabled:pointer-events-none disabled:opacity-50", aiSortEnabled - ? "bg-accent text-accent-foreground" - : "text-muted-foreground hover:text-foreground hover:border-sidebar-border" + ? "bg-accent text-accent-foreground hover:bg-accent" + : "text-muted-foreground hover:bg-muted/80 hover:text-foreground" )} onClick={(e) => { e.preventDefault(); @@ -142,11 +142,11 @@ export function DocumentsFilters({ {activeTypes.length > 0 && ( - + {activeTypes.length} )} diff --git a/surfsense_web/components/report-panel/pdf-viewer.tsx b/surfsense_web/components/report-panel/pdf-viewer.tsx index c4980dd7e..77d0f83a6 100644 --- a/surfsense_web/components/report-panel/pdf-viewer.tsx +++ b/surfsense_web/components/report-panel/pdf-viewer.tsx @@ -3,7 +3,7 @@ import { ZoomInIcon, ZoomOutIcon } from "lucide-react"; import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist"; import * as pdfjsLib from "pdfjs-dist"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { getAuthHeaders } from "@/lib/auth-utils"; @@ -16,6 +16,8 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( interface PdfViewerProps { pdfUrl: string; isPublic?: boolean; + /** Extra actions rendered on the right side of the zoom toolbar (e.g. download, version switcher) */ + toolbarActions?: ReactNode; } interface PageDimensions { @@ -30,7 +32,7 @@ const PAGE_GAP = 12; const SCROLL_DEBOUNCE_MS = 30; const BUFFER_PAGES = 1; -export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) { +export function PdfViewer({ pdfUrl, isPublic = false, toolbarActions }: PdfViewerProps) { const [numPages, setNumPages] = useState(0); const [scale, setScale] = useState(1); const [loading, setLoading] = useState(true); @@ -286,29 +288,33 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
{numPages > 0 && (
- - - {Math.round(scale * 100)}% - - + )} diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index c7a8509ed..ede63d902 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -1,7 +1,7 @@ "use client"; import { useAtomValue, useSetAtom } from "jotai"; -import { Check, ChevronDownIcon, Copy, Pencil, XIcon } from "lucide-react"; +import { Check, ChevronDownIcon, Copy, Download, Pencil, XIcon } from "lucide-react"; import dynamic from "next/dynamic"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; @@ -309,6 +309,7 @@ export function ReportPanelContent({ const isResume = reportContent?.content_type === "typst"; const showReportEditingTier = !isResume; const hasUnsavedChanges = editedMarkdown !== null; + const showDesktopHeader = !!onClose; const handleCancelEditing = useCallback(() => { setEditedMarkdown(null); @@ -316,153 +317,177 @@ export function ReportPanelContent({ setIsEditing(false); }, []); + const exportButton = !isEditing && ( + <> + {isResume ? ( + + ) : ( + + + + + + + + + )} + + ); + + const versionSwitcher = !isEditing && versions.length > 1 && ( + + + + + + {versions.map((v, i) => ( + setActiveReportId(v.id)} + className={v.id === activeReportId ? "bg-accent font-medium" : ""} + > + Version {i + 1} + + ))} + + + ); + + const copyButton = !isEditing && showReportEditingTier && ( + + ); + + const editingActions = showReportEditingTier && + !isReadOnly && + (isEditing ? ( + <> + + + + ) : ( + + )); + return ( <> - {/* Action bar — always visible; buttons are disabled while loading */} -
-
- {/* Export — plain button for resume (typst), dropdown for others */} - {reportContent?.content_type === "typst" ? ( - - ) : ( - - - - - - - - - )} - - {/* Version switcher — only shown when multiple versions exist */} - {versions.length > 1 && ( - - - - - - {versions.map((v, i) => ( - setActiveReportId(v.id)} - className={v.id === activeReportId ? "bg-accent font-medium" : ""} - > - Version {i + 1} - - ))} - - - )} -
- {onClose && ( - - )} -
- - {showReportEditingTier && ( -
-
-

- {reportContent?.title || title} -

-
-
- {!isEditing && ( - )} - {!isReadOnly && - (isEditing ? ( - <> - - - - ) : ( - - ))}
-
+ + {!isResume && ( +
+
+

+ {reportContent?.title || title} +

+
+
+ {versionSwitcher} + {exportButton} + {copyButton} + {editingActions} +
+
+ )} + + ) : ( + !isResume && ( +
+
+

{reportContent?.title || title}

+
+
+ {versionSwitcher} + {exportButton} + {copyButton} + {editingActions} +
+
+ ) )} {/* Report content — skeleton/error/viewer/editor shown only in this area */} @@ -480,6 +505,12 @@ export function ReportPanelContent({ + {versionSwitcher} + {exportButton} + + } /> ) : reportContent.content ? ( isReadOnly ? ( diff --git a/surfsense_web/components/tool-ui/generate-report.tsx b/surfsense_web/components/tool-ui/generate-report.tsx index 32f97b6a4..912028596 100644 --- a/surfsense_web/components/tool-ui/generate-report.tsx +++ b/surfsense_web/components/tool-ui/generate-report.tsx @@ -137,10 +137,9 @@ function ReportCard({ const autoOpenedRef = useRef(false); const [metadata, setMetadata] = useState<{ title: string; - wordCount: number | null; versionLabel: string | null; content: string | null; - }>({ title, wordCount: wordCount ?? null, versionLabel: null, content: null }); + }>({ title, versionLabel: null, content: null }); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -169,10 +168,8 @@ function ReportCard({ } } const resolvedTitle = parsed.data.title || title; - const resolvedWordCount = parsed.data.report_metadata?.word_count ?? wordCount ?? null; setMetadata({ title: resolvedTitle, - wordCount: resolvedWordCount, versionLabel, content: parsed.data.content ?? null, }); @@ -182,7 +179,7 @@ function ReportCard({ openPanel({ reportId, title: resolvedTitle, - wordCount: resolvedWordCount ?? undefined, + wordCount: parsed.data.report_metadata?.word_count ?? wordCount ?? undefined, shareToken, }); } @@ -210,7 +207,6 @@ function ReportCard({ openPanel({ reportId, title: metadata.title, - wordCount: metadata.wordCount ?? undefined, shareToken, }); }; @@ -233,10 +229,8 @@ function ReportCard({ ) : ( <> - {metadata.wordCount != null && `${metadata.wordCount.toLocaleString()} words`} - {metadata.wordCount != null && metadata.versionLabel && ( - - )} + Markdown + {metadata.versionLabel && } {metadata.versionLabel} )} diff --git a/surfsense_web/components/tool-ui/generate-resume.tsx b/surfsense_web/components/tool-ui/generate-resume.tsx index 1290a70ea..4e9d06fbb 100644 --- a/surfsense_web/components/tool-ui/generate-resume.tsx +++ b/surfsense_web/components/tool-ui/generate-resume.tsx @@ -2,6 +2,7 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useAtomValue, useSetAtom } from "jotai"; +import { Dot } from "lucide-react"; import { useParams, usePathname } from "next/navigation"; import * as pdfjsLib from "pdfjs-dist"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -9,6 +10,7 @@ import { z } from "zod"; import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { useMediaQuery } from "@/hooks/use-media-query"; +import { baseApiService } from "@/lib/apis/base-api.service"; import { getAuthHeaders } from "@/lib/auth-utils"; pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( @@ -32,6 +34,18 @@ const GenerateResumeResultSchema = z.object({ error: z.string().nullish(), }); +const ResumeVersionsResponseSchema = z.object({ + id: z.number(), + versions: z + .array( + z.object({ + id: z.number(), + created_at: z.string().nullish(), + }) + ) + .nullish(), +}); + type GenerateResumeArgs = z.infer; type GenerateResumeResult = z.infer; @@ -201,6 +215,7 @@ function ResumeCard({ const autoOpenedRef = useRef(false); const [pdfUrl, setPdfUrl] = useState(null); const [thumbState, setThumbState] = useState<"loading" | "ready" | "error">("loading"); + const [versionLabel, setVersionLabel] = useState(null); useEffect(() => { const previewPath = shareToken @@ -219,6 +234,35 @@ function ResumeCard({ } }, [reportId, title, shareToken, autoOpen, isDesktop, openPanel]); + useEffect(() => { + let cancelled = false; + const fetchVersions = async () => { + try { + const url = shareToken + ? `/api/v1/public/${shareToken}/reports/${reportId}/content` + : `/api/v1/reports/${reportId}/content`; + const rawData = await baseApiService.get(url); + if (cancelled) return; + const parsed = ResumeVersionsResponseSchema.safeParse(rawData); + if (parsed.success) { + const versions = parsed.data.versions; + if (versions && versions.length > 1) { + const idx = versions.findIndex((v) => v.id === reportId); + if (idx >= 0) { + setVersionLabel(`version ${idx + 1}`); + } + } + } + } catch { + // silently ignore — version label is non-critical + } + }; + fetchVersions(); + return () => { + cancelled = true; + }; + }, [reportId, shareToken]); + const onThumbLoad = useCallback(() => setThumbState("ready"), []); const onThumbError = useCallback(() => setThumbState("error"), []); @@ -243,8 +287,12 @@ function ResumeCard({ className="w-full text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none cursor-pointer select-none" >
-

{title}

-

PDF

+

{title}

+

+ PDF + {versionLabel && } + {versionLabel} +