Merge upstream/dev into feature/human-in-the-loop

This commit is contained in:
CREDO23 2026-02-13 20:17:59 +02:00
commit 66a6fb685e
47 changed files with 7257 additions and 4582 deletions

View file

@ -0,0 +1,350 @@
"use client";
import {
Bell,
BellOff,
CheckCheck,
ExternalLink,
Filter,
Info,
type Megaphone,
Rocket,
Wrench,
X,
Zap,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { AnnouncementCategory } from "@/contracts/types/announcement.types";
import { type AnnouncementWithState, useAnnouncements } from "@/hooks/use-announcements";
import { formatRelativeDate } from "@/lib/format-date";
// ---------------------------------------------------------------------------
// Category configuration
// ---------------------------------------------------------------------------
const categoryConfig: Record<
AnnouncementCategory,
{
label: string;
icon: typeof Megaphone;
color: string;
badgeVariant: "default" | "secondary" | "destructive" | "outline";
}
> = {
feature: {
label: "Feature",
icon: Rocket,
color: "text-emerald-500",
badgeVariant: "default",
},
update: {
label: "Update",
icon: Zap,
color: "text-blue-500",
badgeVariant: "secondary",
},
maintenance: {
label: "Maintenance",
icon: Wrench,
color: "text-amber-500",
badgeVariant: "outline",
},
info: {
label: "Info",
icon: Info,
color: "text-muted-foreground",
badgeVariant: "secondary",
},
};
// ---------------------------------------------------------------------------
// Announcement card
// ---------------------------------------------------------------------------
function AnnouncementCard({
announcement,
onMarkRead,
onDismiss,
}: {
announcement: AnnouncementWithState;
onMarkRead: (id: string) => void;
onDismiss: (id: string) => void;
}) {
const config = categoryConfig[announcement.category];
const Icon = config.icon;
return (
<Card
className={`group relative transition-all duration-200 hover:shadow-md ${
!announcement.isRead ? "border-l-4 border-l-primary bg-primary/2" : ""
}`}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 min-w-0">
<div
className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted ${config.color}`}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<CardTitle className="text-base leading-tight">{announcement.title}</CardTitle>
<Badge variant={config.badgeVariant} className="text-[10px] px-1.5 py-0">
{config.label}
</Badge>
{announcement.isImportant && (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 gap-0.5">
<Bell className="h-2.5 w-2.5" />
Important
</Badge>
)}
{!announcement.isRead && (
<span className="h-2 w-2 rounded-full bg-primary shrink-0" />
)}
</div>
<CardDescription className="mt-1 text-xs">
{formatRelativeDate(announcement.date)}
</CardDescription>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
{!announcement.isRead && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onMarkRead(announcement.id)}
>
<CheckCheck className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Mark as read</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onDismiss(announcement.id)}
>
<X className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Dismiss</TooltipContent>
</Tooltip>
</div>
</div>
</CardHeader>
<CardContent className="pb-3">
<p className="text-sm text-muted-foreground leading-relaxed">{announcement.description}</p>
</CardContent>
{announcement.link && (
<CardFooter className="pt-0 pb-4">
<Button variant="outline" size="sm" asChild className="gap-1.5">
<Link
href={announcement.link.url}
target={announcement.link.url.startsWith("http") ? "_blank" : undefined}
onClick={() => onMarkRead(announcement.id)}
>
{announcement.link.label}
<ExternalLink className="h-3 w-3" />
</Link>
</Button>
</CardFooter>
)}
</Card>
);
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
function EmptyState({ hasFilters }: { hasFilters: boolean }) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
{hasFilters ? (
<Filter className="h-7 w-7 text-muted-foreground" />
) : (
<BellOff className="h-7 w-7 text-muted-foreground" />
)}
</div>
<h3 className="text-lg font-semibold">
{hasFilters ? "No matching announcements" : "No announcements"}
</h3>
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
{hasFilters
? "Try adjusting your filters to see more announcements."
: "You're all caught up! New announcements will appear here."}
</p>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function AnnouncementsPage() {
const [activeCategories, setActiveCategories] = useState<AnnouncementCategory[]>([]);
const [showOnlyUnread, setShowOnlyUnread] = useState(false);
const { announcements, unreadCount, markRead, markAllRead, dismiss } = useAnnouncements({
includeDismissed: false,
});
// Apply local filters
const filteredAnnouncements = announcements.filter((a) => {
if (activeCategories.length > 0 && !activeCategories.includes(a.category)) return false;
if (showOnlyUnread && a.isRead) return false;
return true;
});
const hasActiveFilters = activeCategories.length > 0 || showOnlyUnread;
const toggleCategory = (cat: AnnouncementCategory) => {
setActiveCategories((prev) =>
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
);
};
return (
<TooltipProvider delayDuration={0}>
<div className="min-h-screen relative pt-20">
{/* Header */}
<div className="border-b border-border/50">
<div className="max-w-5xl mx-auto relative">
<div className="p-6">
<h1 className="text-4xl font-bold tracking-tight bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 bg-clip-text text-transparent">
Announcements
</h1>
</div>
</div>
</div>
{/* Content */}
<div className="max-w-3xl mx-auto px-6 lg:px-10 pt-8 pb-20">
{/* Toolbar */}
<div className="flex items-center justify-between gap-3 mb-6">
<div className="flex items-center gap-2">
{/* Category filter dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<Filter className="h-3.5 w-3.5" />
Filter
{activeCategories.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
{activeCategories.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuLabel>Categories</DropdownMenuLabel>
<DropdownMenuSeparator />
{(Object.keys(categoryConfig) as AnnouncementCategory[]).map((cat) => {
const cfg = categoryConfig[cat];
const CatIcon = cfg.icon;
return (
<DropdownMenuCheckboxItem
key={cat}
checked={activeCategories.includes(cat)}
onCheckedChange={() => toggleCategory(cat)}
>
<CatIcon className={`mr-2 h-3.5 w-3.5 ${cfg.color}`} />
{cfg.label}
</DropdownMenuCheckboxItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={showOnlyUnread}
onCheckedChange={() => setShowOnlyUnread((v) => !v)}
>
<Bell className="mr-2 h-3.5 w-3.5" />
Unread only
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground"
onClick={() => {
setActiveCategories([]);
setShowOnlyUnread(false);
}}
>
Clear filters
</Button>
)}
</div>
{/* Mark all read */}
{unreadCount > 0 && (
<Button variant="ghost" size="sm" className="gap-1.5 text-xs" onClick={markAllRead}>
<CheckCheck className="h-3.5 w-3.5" />
Mark all as read
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
{unreadCount}
</Badge>
</Button>
)}
</div>
<Separator className="mb-6" />
{/* Announcement list */}
{filteredAnnouncements.length === 0 ? (
<EmptyState hasFilters={hasActiveFilters} />
) : (
<div className="flex flex-col gap-4">
{filteredAnnouncements.map((announcement) => (
<AnnouncementCard
key={announcement.id}
announcement={announcement}
onMarkRead={markRead}
onDismiss={dismiss}
/>
))}
</div>
)}
</div>
</div>
</TooltipProvider>
);
}

View file

@ -28,15 +28,18 @@ import {
// extractWriteTodosFromContent,
hydratePlanStateAtom,
} from "@/atoms/chat/plan-state.atom";
import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Thread } from "@/components/assistant-ui/thread";
import { ChatHeader } from "@/components/new-chat/chat-header";
import { CreateNotionPageToolUI } from "@/components/tool-ui/create-notion-page";
import { ReportPanel } from "@/components/report-panel/report-panel";
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
import { DeleteNotionPageToolUI } from "@/components/tool-ui/delete-notion-page";
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
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 { UpdateNotionPageToolUI } from "@/components/tool-ui/update-notion-page";
@ -131,6 +134,7 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
*/
const TOOLS_WITH_UI = new Set([
"generate_podcast",
"generate_report",
"link_preview",
"display_image",
"delete_notion_page",
@ -170,6 +174,7 @@ export default function NewChatPage() {
const setCurrentThreadState = useSetAtom(currentThreadAtom);
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
const closeReportPanel = useSetAtom(closeReportPanelAtom);
// Get current user for author info in shared chats
const { data: currentUser } = useAtomValue(currentUserAtom);
@ -263,6 +268,7 @@ export default function NewChatPage() {
setMentionedDocuments([]);
setMessageDocumentsMap({});
clearPlanOwnerRegistry(); // Reset plan ownership for new chat
closeReportPanel(); // Close report panel when switching chats
try {
if (urlChatId > 0) {
@ -327,6 +333,7 @@ export default function NewChatPage() {
setMentionedDocumentIds,
setMentionedDocuments,
hydratePlanState,
closeReportPanel,
]);
// Initialize on mount
@ -1635,6 +1642,7 @@ export default function NewChatPage() {
return (
<AssistantRuntimeProvider runtime={runtime}>
<GeneratePodcastToolUI />
<GenerateReportToolUI />
<LinkPreviewToolUI />
<DisplayImageToolUI />
<ScrapeWebpageToolUI />
@ -1644,11 +1652,14 @@ export default function NewChatPage() {
<UpdateNotionPageToolUI />
<DeleteNotionPageToolUI />
{/* <WriteTodosToolUI /> Disabled for now */}
<div className="flex flex-col h-[calc(100dvh-64px)] overflow-hidden">
<Thread
messageThinkingSteps={messageThinkingSteps}
header={<ChatHeader searchSpaceId={searchSpaceId} />}
/>
<div className="flex h-[calc(100dvh-64px)] overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Thread
messageThinkingSteps={messageThinkingSteps}
header={<ChatHeader searchSpaceId={searchSpaceId} />}
/>
</div>
<ReportPanel />
</div>
</AssistantRuntimeProvider>
);

View file

@ -225,3 +225,5 @@ button {
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
@source '../node_modules/streamdown/dist/*.js';
@source '../node_modules/@streamdown/code/dist/*.js';
@source '../node_modules/@streamdown/math/dist/*.js';

View file

@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
import "./globals.css";
import { RootProvider } from "fumadocs-ui/provider/next";
import { Roboto } from "next/font/google";
import { AnnouncementToastProvider } from "@/components/announcements/AnnouncementToastProvider";
import { ElectricProvider } from "@/components/providers/ElectricProvider";
import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvider";
import { I18nProvider } from "@/components/providers/I18nProvider";
@ -124,6 +125,7 @@ export default function RootLayout({
</ElectricProvider>
</ReactQueryClientProvider>
<Toaster />
<AnnouncementToastProvider />
</RootProvider>
</ThemeProvider>
</I18nProvider>

View file

@ -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 */

View file

@ -0,0 +1,52 @@
import { atom } from "jotai";
interface ReportPanelState {
isOpen: boolean;
reportId: number | null;
title: string | null;
wordCount: number | null;
/** When set, uses public endpoints for fetching report data (public shared chat) */
shareToken: string | null;
}
const initialState: ReportPanelState = {
isOpen: false,
reportId: null,
title: null,
wordCount: null,
shareToken: null,
};
/** Core atom holding the report panel state */
export const reportPanelAtom = atom<ReportPanelState>(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,
shareToken,
}: { reportId: number; title: string; wordCount?: number; shareToken?: string | null }
) => {
set(reportPanelAtom, {
isOpen: true,
reportId,
title,
wordCount: wordCount ?? null,
shareToken: shareToken ?? null,
});
}
);
/** Action atom to close the report panel */
export const closeReportPanelAtom = atom(null, (_, set) => {
set(reportPanelAtom, initialState);
});

View file

@ -0,0 +1,90 @@
"use client";
import { Megaphone } from "lucide-react";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import type { Announcement } from "@/contracts/types/announcement.types";
import { announcements } from "@/lib/announcements/announcements-data";
import {
isAnnouncementToasted,
markAnnouncementRead,
markAnnouncementToasted,
} from "@/lib/announcements/announcements-storage";
/** Map announcement category to the Sonner toast method */
const categoryToVariant: Record<string, "info" | "warning" | "success"> = {
update: "info",
feature: "success",
maintenance: "warning",
info: "info",
};
/** Show a single announcement as a toast */
function showAnnouncementToast(announcement: Announcement) {
const variant = categoryToVariant[announcement.category] ?? "info";
const options = {
description: truncateText(announcement.description, 120),
duration: 12000,
icon: <Megaphone className="h-4 w-4" />,
action: announcement.link
? {
label: announcement.link.label,
onClick: () => {
if (announcement.link?.url.startsWith("http")) {
window.open(announcement.link.url, "_blank");
} else if (announcement.link?.url) {
window.location.href = announcement.link.url;
}
},
}
: undefined,
onDismiss: () => {
markAnnouncementRead(announcement.id);
},
};
toast[variant](announcement.title, options);
markAnnouncementToasted(announcement.id);
}
/**
* Global provider that shows important announcements as toast notifications.
*
* Place this component once at the root layout level (alongside <Toaster />).
* On mount, it checks for unread important announcements that haven't been
* shown as toasts yet, and displays them with a short stagger delay.
*/
export function AnnouncementToastProvider() {
const hasChecked = useRef(false);
useEffect(() => {
// Only run once per page load
if (hasChecked.current) return;
hasChecked.current = true;
// Small delay to let the page settle before showing toasts
const timer = setTimeout(() => {
const importantUntoasted = announcements.filter(
(a) => a.isImportant && !isAnnouncementToasted(a.id)
);
// Show each important announcement as a toast with stagger
for (let i = 0; i < importantUntoasted.length; i++) {
const announcement = importantUntoasted[i];
setTimeout(() => showAnnouncementToast(announcement), i * 800);
}
}, 1500); // Initial delay for page to settle
return () => clearTimeout(timer);
}, []);
// This component renders nothing — it only triggers side effects
return null;
}
/** Truncate text to a maximum length, adding ellipsis if needed */
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength).trimEnd()}...`;
}

View file

@ -33,6 +33,10 @@ export function FooterNew() {
title: "Contact Us",
href: "/contact",
},
{
title: "Announcements",
href: "/announcements",
},
];
const socials = [

View file

@ -4,6 +4,7 @@ import {
IconBrandGithub,
IconBrandReddit,
IconMenu2,
IconSpeakerphone,
IconX,
} from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
@ -12,6 +13,7 @@ import { useEffect, useState } from "react";
import { SignInButton } from "@/components/auth/sign-in-button";
import { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { useAnnouncements } from "@/hooks/use-announcements";
import { useGithubStars } from "@/hooks/use-github-stars";
import { cn } from "@/lib/utils";
@ -47,7 +49,11 @@ export const Navbar = () => {
const DesktopNav = ({ navItems, isScrolled }: any) => {
const [hovered, setHovered] = useState<number | null>(null);
const [mounted, setMounted] = useState(false);
const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars();
const { unreadCount } = useAnnouncements();
useEffect(() => setMounted(true), []);
return (
<motion.div
onMouseLeave={() => {
@ -118,6 +124,17 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
</span>
)}
</Link>
<Link
href="/announcements"
className="relative hidden rounded-full p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors md:flex items-center justify-center"
>
<IconSpeakerphone className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
{mounted && unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</Link>
<ThemeTogglerComponent />
<SignInButton variant="desktop" />
</div>
@ -127,7 +144,11 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
const MobileNav = ({ navItems, isScrolled }: any) => {
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars();
const { unreadCount } = useAnnouncements();
useEffect(() => setMounted(true), []);
return (
<motion.div
@ -212,6 +233,17 @@ const MobileNav = ({ navItems, isScrolled }: any) => {
</span>
)}
</Link>
<Link
href="/announcements"
className="relative flex items-center justify-center rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors touch-manipulation"
>
<IconSpeakerphone className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
{mounted && unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</Link>
<ThemeTogglerComponent />
</div>
<SignInButton variant="mobile" />

View file

@ -2,7 +2,15 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Inbox, LogOut, PencilIcon, SquareLibrary, Trash2 } from "lucide-react";
import {
AlertTriangle,
Inbox,
LogOut,
Megaphone,
PencilIcon,
SquareLibrary,
Trash2,
} from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@ -23,6 +31,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
import { useAnnouncements } from "@/hooks/use-announcements";
import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { logout } from "@/lib/auth-utils";
@ -65,6 +74,9 @@ export function LayoutDataProvider({
const queryClient = useQueryClient();
const { theme, setTheme } = useTheme();
// Announcements
const { unreadCount: announcementUnreadCount } = useAnnouncements();
// Atoms
const { data: user } = useAtomValue(currentUserAtom);
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
@ -293,8 +305,15 @@ export function LayoutDataProvider({
icon: SquareLibrary,
isActive: pathname?.includes("/documents"),
},
{
title: "Announcements",
url: "/announcements",
icon: Megaphone,
isActive: pathname?.includes("/announcements"),
badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined,
},
],
[searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount]
[searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount, announcementUnreadCount]
);
// Handlers

View file

@ -126,7 +126,7 @@ export function SearchSpaceAvatar({
type="button"
onClick={onClick}
className={cn(
"relative flex items-center justify-center rounded-lg font-semibold text-white transition-all",
"relative flex items-center justify-center rounded-lg font-semibold text-white transition-all select-none",
"hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
sizeClasses,
isActive && "ring-2 ring-primary ring-offset-1 ring-offset-background"

View file

@ -111,7 +111,7 @@ function UserAvatar({
return (
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white select-none"
style={{ backgroundColor: bgColor }}
>
{initials}

View file

@ -1,23 +1,67 @@
import { createCodePlugin } from "@streamdown/code";
import { createMathPlugin } from "@streamdown/math";
import Image from "next/image";
import { Streamdown, type StreamdownProps } from "streamdown";
import "katex/dist/katex.min.css";
import { cn } from "@/lib/utils";
const code = createCodePlugin({
themes: ["nord", "nord"],
});
const math = createMathPlugin({
singleDollarTextMath: true,
});
interface MarkdownViewerProps {
content: string;
className?: string;
}
/**
* If the entire content is wrapped in a single ```markdown or ```md
* code fence, strip the fence so the inner markdown renders properly.
*/
function stripOuterMarkdownFence(content: string): string {
const trimmed = content.trim();
const match = trimmed.match(/^```(?:markdown|md)?\s*\n([\s\S]+?)\n```\s*$/);
return match ? match[1] : content;
}
/**
* Convert various LaTeX delimiter styles to the dollar-sign syntax
* that remark-math understands, and normalise edge-cases that
* commonly appear in LLM-generated markdown.
*
* \[...\] $$ ... $$ (block / display math)
* \(...\) $ ... $ (inline math)
* same-line $$$$ $ ... $ (inline math display math
* can't live inside table cells)
* `$$$$` $$ $$ (strip wrapping backtick code)
* `$$` $ $ (strip wrapping backtick code)
*/
function convertLatexDelimiters(content: string): string {
// 1. Block math: \[...\] → $$...$$
content = content.replace(/\\\[([\s\S]*?)\\\]/g, (_match, inner) => {
return `$$${inner}$$`;
});
// 2. Inline math: \(...\) → $...$
content = content.replace(/\\\(([\s\S]*?)\\\)/g, (_match, inner) => {
return `$${inner}$`;
});
// 3. Strip backtick wrapping around math: `$$...$$` → $$...$$ and `$...$` → $...$
content = content.replace(/`(\${1,2})((?:(?!\1).)+)\1`/g, "$1$2$1");
// 4. Same-line $$...$$ → $...$ (inline math) so it works inside table cells.
// True display math has $$ on its own line, so this only affects inline usage.
content = content.replace(/\$\$([^\n]+?)\$\$/g, (_match, inner) => {
return `$${inner}$`;
});
return content;
}
export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
const processedContent = convertLatexDelimiters(stripOuterMarkdownFence(content));
const components: StreamdownProps["components"] = {
// Define custom components for markdown elements
callout: ({ children, ...props }) => (
<div
className="my-4 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950"
{...props}
>
{children}
</div>
),
p: ({ children, ...props }) => (
<p className="my-2" {...props}>
{children}
@ -71,42 +115,41 @@ export function MarkdownViewer({ content, className }: MarkdownViewerProps) {
/>
),
table: ({ ...props }) => (
<div className="overflow-x-auto my-4">
<table className="min-w-full divide-y divide-border" {...props} />
<div className="overflow-x-auto my-4 rounded-lg border border-border w-full">
<table className="w-full divide-y divide-border" {...props} />
</div>
),
th: ({ ...props }) => <th className="px-3 py-2 text-left font-medium bg-muted" {...props} />,
td: ({ ...props }) => <td className="px-3 py-2 border-t border-border" {...props} />,
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match;
if (isInline) {
return (
<code className="bg-muted px-1 py-0.5 rounded text-xs" {...props}>
{children}
</code>
);
}
// For code blocks, let Streamdown handle syntax highlighting
return (
<code className={className} {...props}>
{children}
</code>
);
},
th: ({ ...props }) => (
<th
className="px-4 py-2.5 text-left text-sm font-semibold text-muted-foreground/80 bg-muted/30 border-r border-border/40 last:border-r-0"
{...props}
/>
),
td: ({ ...props }) => (
<td
className="px-4 py-2.5 text-sm border-t border-r border-border/40 last:border-r-0"
{...props}
/>
),
};
return (
<div
className={cn(
"prose prose-sm dark:prose-invert max-w-none overflow-hidden [&_pre]:overflow-x-auto [&_code]:wrap-break-word [&_table]:block [&_table]:overflow-x-auto",
"max-w-none overflow-hidden",
"[&_[data-streamdown=code-block-header]]:!bg-transparent",
"[&_[data-streamdown=code-block]>*]:!border-none [&_[data-streamdown=code-block]>*]:![box-shadow:none]",
"[&_[data-streamdown=code-block-download-button]]:!hidden",
className
)}
>
<Streamdown components={components} shikiTheme={["github-light", "github-dark"]}>
{content}
<Streamdown
components={components}
plugins={{ code, math }}
controls={{ code: true }}
mode="static"
>
{processedContent}
</Streamdown>
</div>
);

View file

@ -138,6 +138,26 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
return (
<div className={cn("flex items-center gap-1", className)}>
{/* Globe indicator when public snapshots exist - clicks to settings */}
{hasPublicSnapshots && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => router.push(`/dashboard/${params.search_space_id}/settings`)}
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
>
<Globe className="h-4 w-4 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>
{snapshotCount === 1
? "This chat has a public link"
: `This chat has ${snapshotCount} public links`}
</TooltipContent>
</Tooltip>
)}
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
@ -242,26 +262,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
</div>
</PopoverContent>
</Popover>
{/* Globe indicator when public snapshots exist - clicks to settings */}
{hasPublicSnapshots && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => router.push(`/dashboard/${params.search_space_id}/settings`)}
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
>
<Globe className="h-4 w-4 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>
{snapshotCount === 1
? "This chat has a public link"
: `This chat has ${snapshotCount} public links`}
</TooltipContent>
</Tooltip>
)}
</div>
);
}

View file

@ -2,8 +2,10 @@
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { Navbar } from "@/components/homepage/navbar";
import { ReportPanel } from "@/components/report-panel/report-panel";
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
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 { Spinner } from "@/components/ui/spinner";
@ -42,12 +44,16 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) {
<AssistantRuntimeProvider runtime={runtime}>
{/* Tool UIs for rendering tool results */}
<GeneratePodcastToolUI />
<GenerateReportToolUI />
<LinkPreviewToolUI />
<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,490 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { ChevronDownIcon, 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 { MarkdownViewer } from "@/components/markdown-viewer";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle } from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { useMediaQuery } from "@/hooks/use-media-query";
import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
/**
* Zod schema for a single version entry
*/
const VersionInfoSchema = z.object({
id: z.number(),
created_at: z.string().nullish(),
});
/**
* 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({
status: z.enum(["ready", "failed"]).nullish(),
error_message: z.string().nullish(),
word_count: z.number().nullish(),
char_count: z.number().nullish(),
section_count: z.number().nullish(),
})
.nullish(),
report_group_id: z.number().nullish(),
versions: z.array(VersionInfoSchema).nullish(),
});
type ReportContentResponse = z.infer<typeof ReportContentResponseSchema>;
type VersionInfo = z.infer<typeof VersionInfoSchema>;
/**
* 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,
onClose,
insideDrawer = false,
shareToken,
}: {
reportId: number;
title: string;
onClose?: () => void;
/** When true, adjusts dropdown behavior to work inside a Vaul drawer on mobile */
insideDrawer?: boolean;
/** When set, uses public endpoint for fetching report data (public shared chat) */
shareToken?: string | null;
}) {
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" | "md" | null>(null);
// Version state
const [activeReportId, setActiveReportId] = useState(reportId);
const [versions, setVersions] = useState<VersionInfo[]>([]);
// Reset active version when the external reportId changes (e.g. clicking a different card)
useEffect(() => {
setActiveReportId(reportId);
}, [reportId]);
// Fetch report content (re-runs when activeReportId changes for version switching)
useEffect(() => {
let cancelled = false;
const fetchContent = async () => {
setIsLoading(true);
setError(null);
try {
const url = shareToken
? `/api/v1/public/${shareToken}/reports/${activeReportId}/content`
: `/api/v1/reports/${activeReportId}/content`;
const rawData = await baseApiService.get<unknown>(url);
if (cancelled) return;
const parsed = ReportContentResponseSchema.safeParse(rawData);
if (parsed.success) {
// Check if the report was marked as failed in metadata
if (parsed.data.report_metadata?.status === "failed") {
setError(parsed.data.report_metadata?.error_message || "Report generation failed");
} else {
setReportContent(parsed.data);
// Update versions from the response
if (parsed.data.versions && parsed.data.versions.length > 0) {
setVersions(parsed.data.versions);
}
}
} 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;
};
}, [activeReportId, shareToken]);
// 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" | "md") => {
setExporting(format);
const safeTitle =
title
.replace(/[^a-zA-Z0-9 _-]/g, "_")
.trim()
.slice(0, 80) || "report";
try {
if (format === "md") {
// Download markdown content directly as a .md file
if (!reportContent?.content) return;
const blob = new Blob([reportContent.content], {
type: "text/markdown;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${safeTitle}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${activeReportId}/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 = `${safeTitle}.${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);
}
},
[activeReportId, title, reportContent?.content]
);
// Show full-page skeleton only on initial load (no data loaded yet).
// Once we have versions/content from a prior fetch, keep the action bar visible.
const hasLoadedBefore = versions.length > 0 || reportContent !== null;
if (isLoading && !hasLoadedBefore) {
return (
<>
{/* Minimal top bar with close button even during initial load */}
<div className="flex items-center justify-end px-4 py-2 shrink-0">
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close report panel</span>
</Button>
)}
</div>
<ReportPanelSkeleton />
</>
);
}
const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId);
return (
<>
{/* Action bar — always visible after initial load */}
<div className="flex items-center justify-between px-4 py-2 shrink-0">
<div className="flex items-center gap-2">
{/* Copy button */}
<Button
variant="outline"
size="sm"
onClick={handleCopy}
disabled={isLoading || !reportContent?.content}
className="h-8 min-w-[80px] px-3.5 py-4 text-[15px]"
>
{copied ? "Copied" : "Copy"}
</Button>
{/* Export dropdown */}
<DropdownMenu modal={insideDrawer ? false : undefined}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={isLoading || !reportContent?.content}
className="h-8 px-3.5 py-4 text-[15px] gap-1.5"
>
Export
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={`min-w-[180px]${insideDrawer ? " z-[100]" : ""}`}
>
<DropdownMenuItem onClick={() => handleExport("md")}>
Download Markdown
</DropdownMenuItem>
{/* PDF/DOCX export requires server-side conversion via authenticated endpoint.
Hide for public viewers who have no auth token. */}
{!shareToken && (
<>
<DropdownMenuItem
onClick={() => handleExport("pdf")}
disabled={exporting !== null}
>
{exporting === "pdf" && <Spinner size="xs" />}
Download PDF
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExport("docx")}
disabled={exporting !== null}
>
{exporting === "docx" && <Spinner size="xs" />}
Download DOCX
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Version switcher — only shown when multiple versions exist */}
{versions.length > 1 &&
(insideDrawer ? (
/* Mobile: compact dropdown */
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-3.5 py-4 text-[15px] gap-1.5"
>
v{activeVersionIndex + 1}
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[120px] z-[100]">
{versions.map((v, i) => (
<DropdownMenuItem
key={v.id}
onClick={() => setActiveReportId(v.id)}
className={v.id === activeReportId ? "bg-accent font-medium" : ""}
>
Version {i + 1}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
/* Desktop: inline version buttons */
<div className="flex items-center gap-1">
<div className="flex items-center gap-0.5 rounded-lg border bg-muted/30 p-0.5">
{versions.map((v, i) => (
<button
key={v.id}
type="button"
onClick={() => setActiveReportId(v.id)}
className={`px-2 py-0.5 rounded-md text-xs font-medium transition-colors ${
v.id === activeReportId
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
v{i + 1}
</button>
))}
</div>
<span className="text-[10px] text-muted-foreground tabular-nums ml-1">
{activeVersionIndex + 1} of {versions.length}
</span>
</div>
))}
</div>
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close report panel</span>
</Button>
)}
</div>
{/* Report content — skeleton/error/content shown only in this area */}
<div className="flex-1 overflow-y-auto scrollbar-thin">
{isLoading ? (
<ReportPanelSkeleton />
) : error || !reportContent ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
<div>
<p className="font-medium text-foreground">Failed to load report</p>
<p className="text-sm text-red-500 mt-1">{error || "An unknown error occurred"}</p>
</div>
</div>
) : (
<div className="px-5 py-5">
{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"}
onClose={closePanel}
shareToken={panelState.shareToken}
/>
</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"}
insideDrawer
shareToken={panelState.shareToken}
/>
</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

@ -8,7 +8,8 @@ import {
FileTextIcon,
UserIcon,
} from "lucide-react";
import { Component, type ReactNode, useCallback } from "react";
import Image from "next/image";
import { Component, type ReactNode, useCallback, useState } from "react";
import { z } from "zod";
import { Card, CardContent } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@ -126,6 +127,30 @@ function formatWordCount(count: number): string {
return `${count} words`;
}
/**
* Favicon component that fetches the site icon via Google's favicon service,
* falling back to BookOpenIcon on error.
*/
function SiteFavicon({ domain }: { domain: string }) {
const [failed, setFailed] = useState(false);
if (failed) {
return <BookOpenIcon className="size-5 text-primary" />;
}
return (
<Image
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`}
alt={`${domain} favicon`}
width={28}
height={28}
className="size-5 sm:size-7 rounded-sm"
onError={() => setFailed(true)}
unoptimized
/>
);
}
/**
* Article card component for displaying scraped webpage content
*/
@ -198,27 +223,35 @@ export function Article({
}}
>
{/* Header */}
<CardContent className="p-4">
<div className="flex items-start gap-3">
{/* Icon */}
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<BookOpenIcon className="size-5 text-primary" />
</div>
<CardContent className="p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
{/* Favicon / Icon */}
{domain ? (
<div className="flex size-8 sm:size-10 shrink-0 items-center justify-center">
<SiteFavicon domain={domain} />
</div>
) : (
<div className="flex size-8 sm:size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<BookOpenIcon className="size-4 sm:size-5 text-primary" />
</div>
)}
{/* Content */}
<div className="flex-1 min-w-0">
{/* Title */}
<h3 className="font-semibold text-sm line-clamp-2 group-hover:text-primary transition-colors">
<h3 className="font-semibold text-xs sm:text-sm line-clamp-2 group-hover:text-primary transition-colors">
{title}
</h3>
{/* Description */}
{description && (
<p className="text-muted-foreground text-xs mt-1 line-clamp-2">{description}</p>
<p className="text-muted-foreground text-[10px] sm:text-xs mt-1 line-clamp-2">
{description}
</p>
)}
{/* Metadata row */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-2 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-x-2 sm:gap-x-3 gap-y-1 mt-1.5 sm:mt-2 text-[10px] sm:text-xs text-muted-foreground">
{domain && (
<Tooltip>
<TooltipTrigger asChild>
@ -274,13 +307,6 @@ export function Article({
)}
</div>
</div>
{/* External link indicator */}
{href && (
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</div>
)}
</div>
{/* Response actions */}

View file

@ -0,0 +1,303 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai";
import { Dot, FileTextIcon } from "lucide-react";
import { useParams, usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { z } from "zod";
import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { baseApiService } from "@/lib/apis/base-api.service";
/**
* Zod schemas for runtime validation
*/
const GenerateReportArgsSchema = z.object({
topic: z.string(),
source_content: z.string(),
report_style: z.string().nullish(),
user_instructions: z.string().nullish(),
parent_report_id: z.number().nullish(),
});
const GenerateReportResultSchema = z.object({
status: z.enum(["ready", "failed"]),
report_id: z.number().nullish(),
title: z.string().nullish(),
word_count: z.number().nullish(),
message: z.string().nullish(),
error: z.string().nullish(),
});
const ReportMetadataResponseSchema = z.object({
id: z.number(),
title: z.string(),
report_metadata: z
.object({
status: z.enum(["ready", "failed"]).nullish(),
error_message: z.string().nullish(),
word_count: z.number().nullish(),
section_count: z.number().nullish(),
})
.nullish(),
report_group_id: z.number().nullish(),
versions: z
.array(
z.object({
id: z.number(),
created_at: z.string().nullish(),
})
)
.nullish(),
});
/**
* Types derived from Zod schemas
*/
type GenerateReportArgs = z.infer<typeof GenerateReportArgsSchema>;
type GenerateReportResult = z.infer<typeof GenerateReportResultSchema>;
/**
* Loading state component shown while report is being generated.
* Matches the compact card layout of the completed ReportCard.
*/
function ReportGeneratingState({ topic }: { topic: string }) {
return (
<div className="my-4 overflow-hidden rounded-xl border bg-card">
<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="flex size-8 sm:size-12 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="size-4 sm:size-6 text-primary" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight line-clamp-2">
{topic}
</h3>
<TextShimmerLoader text="Putting things together" size="sm" />
</div>
</div>
</div>
);
}
/**
* Error state component shown when report generation fails
*/
function ReportErrorState({ title, error }: { title: string; error: string }) {
return (
<div className="my-4 overflow-hidden rounded-xl border bg-card">
<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="flex size-8 sm:size-12 shrink-0 items-center justify-center rounded-lg bg-muted/60">
<FileTextIcon className="size-4 sm:size-6 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-muted-foreground text-sm sm:text-base leading-tight line-clamp-2">
{title}
</h3>
<p className="text-muted-foreground/60 text-[11px] sm:text-xs mt-0.5 truncate">{error}</p>
</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({
reportId,
title,
wordCount,
shareToken,
}: {
reportId: number;
title: string;
wordCount?: number;
/** When set, uses public endpoint for fetching report data */
shareToken?: string | null;
}) {
const openPanel = useSetAtom(openReportPanelAtom);
const panelState = useAtomValue(reportPanelAtom);
const [metadata, setMetadata] = useState<{
title: string;
wordCount: number | null;
versionLabel: string | null;
}>({ title, wordCount: wordCount ?? null, versionLabel: null });
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch lightweight metadata (title + counts + version info)
useEffect(() => {
let cancelled = false;
const fetchMetadata = async () => {
setIsLoading(true);
setError(null);
try {
const url = shareToken
? `/api/v1/public/${shareToken}/reports/${reportId}/content`
: `/api/v1/reports/${reportId}/content`;
const rawData = await baseApiService.get<unknown>(url);
if (cancelled) return;
const parsed = ReportMetadataResponseSchema.safeParse(rawData);
if (parsed.success) {
// Check if report was marked as failed in metadata
if (parsed.data.report_metadata?.status === "failed") {
setError(parsed.data.report_metadata?.error_message || "Report generation failed");
} else {
// Determine version label from versions array
let versionLabel: string | null = null;
const versions = parsed.data.versions;
if (versions && versions.length > 1) {
const idx = versions.findIndex((v) => v.id === reportId);
if (idx >= 0) {
versionLabel = `version ${idx + 1}`;
}
}
setMetadata({
title: parsed.data.title || title,
wordCount: parsed.data.report_metadata?.word_count ?? wordCount ?? null,
versionLabel,
});
}
}
} catch {
if (!cancelled) setError("No report found");
} finally {
if (!cancelled) setIsLoading(false);
}
};
fetchMetadata();
return () => {
cancelled = true;
};
}, [reportId, title, wordCount, shareToken]);
// Show non-clickable error card for any error (failed status, not found, etc.)
if (!isLoading && error) {
return <ReportErrorState title={title} error={error} />;
}
const isActive = panelState.isOpen && panelState.reportId === reportId;
const handleOpen = () => {
openPanel({
reportId,
title: metadata.title,
wordCount: metadata.wordCount ?? undefined,
shareToken,
});
};
return (
<div
className={`my-4 overflow-hidden rounded-xl border bg-card transition-colors ${isActive ? "ring-1 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-5 sm:px-6 sm:py-6 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">
<FileTextIcon className="size-4 sm:size-6 text-primary" />
</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}
</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" />
) : (
<>
{metadata.wordCount != null && `${metadata.wordCount.toLocaleString()} words`}
{metadata.wordCount != null && metadata.versionLabel && (
<Dot className="inline size-4" />
)}
{metadata.versionLabel}
</>
)}
</p>
</div>
</button>
</div>
);
}
/**
* Generate Report Tool UI Component
*
* This component is registered with assistant-ui to render custom UI
* 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>({
toolName: "generate_report",
render: function GenerateReportUI({ args, result, status }) {
const params = useParams();
const pathname = usePathname();
const isPublicRoute = pathname?.startsWith("/public/");
const shareToken = isPublicRoute && typeof params?.token === "string" ? params.token : null;
const topic = args.topic || "Report";
// Loading state - tool is still running (LLM generating report)
if (status.type === "running" || status.type === "requires-action") {
return <ReportGeneratingState topic={topic} />;
}
// Incomplete/cancelled state
if (status.type === "incomplete") {
if (status.reason === "cancelled") {
return (
<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") {
return (
<ReportErrorState
title={topic}
error={typeof status.error === "string" ? status.error : "An error occurred"}
/>
);
}
}
// No result yet
if (!result) {
return <ReportGeneratingState topic={topic} />;
}
// Failed result
if (result.status === "failed") {
return (
<ReportErrorState
title={result.title || topic}
error={result.error || "Generation failed"}
/>
);
}
// Ready with report_id
if (result.status === "ready" && result.report_id) {
return (
<ReportCard
reportId={result.report_id}
title={result.title || topic}
wordCount={result.word_count ?? undefined}
shareToken={shareToken}
/>
);
}
// Fallback - missing required data
return <ReportErrorState title={topic} error="Missing report ID" />;
},
});

View file

@ -32,6 +32,7 @@ export {
DisplayImageToolUI,
} from "./display-image";
export { GeneratePodcastToolUI } from "./generate-podcast";
export { GenerateReportToolUI } from "./generate-report";
export {
Image,
ImageErrorBoundary,

View file

@ -87,20 +87,9 @@ function ScrapeCancelledState({ url }: { url: string }) {
* Parsed Article component with error handling
*/
function ParsedArticle({ result }: { result: unknown }) {
const article = parseSerializableArticle(result);
const { description, ...article } = parseSerializableArticle(result);
return (
<Article
{...article}
maxWidth="480px"
responseActions={[{ id: "open", label: "Open Source", variant: "default" }]}
onResponseAction={(id) => {
if (id === "open" && article.href) {
window.open(article.href, "_blank", "noopener,noreferrer");
}
}}
/>
);
return <Article {...article} maxWidth="480px" />;
}
/**

View file

@ -0,0 +1,42 @@
/**
* Announcement system types
*
* Frontend-only announcement system that supports:
* - Multiple announcement categories (update, feature, maintenance, info)
* - Important flag for toast notifications
* - Read/dismissed state tracking via localStorage
*/
/** Announcement category */
export type AnnouncementCategory = "update" | "feature" | "maintenance" | "info";
/** Single announcement entry */
export interface Announcement {
/** Unique identifier */
id: string;
/** Short title */
title: string;
/** Full description (supports basic text) */
description: string;
/** Category for visual styling and filtering */
category: AnnouncementCategory;
/** ISO date string of when the announcement was published */
date: string;
/** If true, the user will see a toast notification for this announcement */
isImportant: boolean;
/** Optional CTA link */
link?: {
label: string;
url: string;
};
}
/** State stored in localStorage for tracking user interactions */
export interface AnnouncementUserState {
/** IDs of announcements the user has read (clicked/viewed) */
readIds: string[];
/** IDs of important announcements already shown as toasts */
toastedIds: string[];
/** IDs of announcements the user has explicitly dismissed */
dismissedIds: string[];
}

View file

@ -0,0 +1,119 @@
"use client";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import type { Announcement, AnnouncementCategory } from "@/contracts/types/announcement.types";
import { announcements } from "@/lib/announcements/announcements-data";
import {
dismissAnnouncement,
getAnnouncementState,
isAnnouncementDismissed,
isAnnouncementRead,
markAllAnnouncementsRead,
markAnnouncementRead,
} from "@/lib/announcements/announcements-storage";
// ---------------------------------------------------------------------------
// External-store plumbing so React re-renders when localStorage changes
// ---------------------------------------------------------------------------
let stateVersion = 0;
const listeners = new Set<() => void>();
function subscribe(callback: () => void) {
listeners.add(callback);
return () => listeners.delete(callback);
}
function getSnapshot() {
return stateVersion;
}
function getServerSnapshot() {
return 0;
}
/** Bump the version so useSyncExternalStore triggers a re-render */
function notify() {
stateVersion++;
for (const listener of listeners) listener();
}
// ---------------------------------------------------------------------------
// Enriched announcement with read/dismissed state
// ---------------------------------------------------------------------------
export interface AnnouncementWithState extends Announcement {
isRead: boolean;
isDismissed: boolean;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
interface UseAnnouncementsOptions {
/** Filter by category */
category?: AnnouncementCategory;
/** If true, include dismissed announcements (default: false) */
includeDismissed?: boolean;
}
export function useAnnouncements(options: UseAnnouncementsOptions = {}) {
const { category, includeDismissed = false } = options;
// Subscribe to state changes (re-renders when localStorage state is bumped)
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const enriched: AnnouncementWithState[] = useMemo(() => {
let items = announcements.map((a) => ({
...a,
isRead: isAnnouncementRead(a.id),
isDismissed: isAnnouncementDismissed(a.id),
}));
if (category) {
items = items.filter((a) => a.category === category);
}
if (!includeDismissed) {
items = items.filter((a) => !a.isDismissed);
}
// Sort by date descending (newest first)
items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return items;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [category, includeDismissed, stateVersion]);
const unreadCount = useMemo(
() => enriched.filter((a) => !a.isRead && !a.isDismissed).length,
[enriched]
);
const handleMarkRead = useCallback((id: string) => {
markAnnouncementRead(id);
notify();
}, []);
const handleMarkAllRead = useCallback(() => {
const state = getAnnouncementState();
const unreadIds = announcements.filter((a) => !state.readIds.includes(a.id)).map((a) => a.id);
markAllAnnouncementsRead(unreadIds);
notify();
}, []);
const handleDismiss = useCallback((id: string) => {
dismissAnnouncement(id);
markAnnouncementRead(id);
notify();
}, []);
return {
announcements: enriched,
unreadCount,
markRead: handleMarkRead,
markAllRead: handleMarkAllRead,
dismiss: handleDismiss,
};
}

View file

@ -0,0 +1,65 @@
import type { Announcement } from "@/contracts/types/announcement.types";
/**
* Static announcements data.
*
* To add a new announcement, append an entry to this array.
* Set `isImportant: true` to trigger a toast notification for the user.
*
* This file can be replaced with an API call in the future.
*/
export const announcements: Announcement[] = [
{
id: "2026-02-12-announcement-syste",
title: "Introducing Announcements",
description:
"Stay up to date with the latest SurfSense news! Important announcements will appear as toast notifications so you never miss critical updates. Visit the Announcements page from the sidebar to browse all past announcements.",
category: "feature",
date: "2026-02-12T00:00:00Z",
isImportant: true,
link: {
label: "Learn more",
url: "/changelog",
},
},
// {
// id: "2026-02-10-podcast-improvements",
// title: "Podcast Generation Improvements",
// description:
// "We've improved podcast generation with faster processing, better audio quality, and support for longer documents. Try it out in any search space.",
// category: "update",
// date: "2026-02-10T00:00:00Z",
// isImportant: false,
// },
// {
// id: "2026-02-08-scheduled-maintenance",
// title: "Scheduled Maintenance — Feb 15",
// description:
// "SurfSense will undergo scheduled maintenance on February 15, 2026 from 2:00 AM to 4:00 AM UTC. During this window, the service may be temporarily unavailable. We apologize for any inconvenience.",
// category: "maintenance",
// date: "2026-02-08T00:00:00Z",
// isImportant: true,
// },
// {
// id: "2026-02-05-new-connectors",
// title: "New Connectors Available",
// description:
// "We've added support for new connectors including Linear, Jira, and Confluence. Connect your project management tools and start chatting with your data.",
// category: "feature",
// date: "2026-02-05T00:00:00Z",
// isImportant: false,
// link: {
// label: "View connectors",
// url: "#connectors",
// },
// },
// {
// id: "2026-01-28-team-collaboration",
// title: "Enhanced Team Collaboration",
// description:
// "Shared search spaces now support real-time mentions, comment threads, and role-based access control. Invite your team and collaborate more effectively.",
// category: "feature",
// date: "2026-01-28T00:00:00Z",
// isImportant: false,
// },
];

View file

@ -0,0 +1,107 @@
import type { AnnouncementUserState } from "@/contracts/types/announcement.types";
const STORAGE_KEY = "surfsense_announcements_state";
const defaultState: AnnouncementUserState = {
readIds: [],
toastedIds: [],
dismissedIds: [],
};
/**
* Get the current announcement user state from localStorage
*/
export function getAnnouncementState(): AnnouncementUserState {
if (typeof window === "undefined") return defaultState;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return defaultState;
const parsed = JSON.parse(raw) as Partial<AnnouncementUserState>;
return {
readIds: Array.isArray(parsed.readIds) ? parsed.readIds : [],
toastedIds: Array.isArray(parsed.toastedIds) ? parsed.toastedIds : [],
dismissedIds: Array.isArray(parsed.dismissedIds) ? parsed.dismissedIds : [],
};
} catch {
return defaultState;
}
}
/**
* Save announcement user state to localStorage
*/
function saveAnnouncementState(state: AnnouncementUserState): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// Silently fail if localStorage is full or unavailable
}
}
/**
* Mark an announcement as read
*/
export function markAnnouncementRead(id: string): void {
const state = getAnnouncementState();
if (!state.readIds.includes(id)) {
state.readIds.push(id);
saveAnnouncementState(state);
}
}
/**
* Mark all announcements as read
*/
export function markAllAnnouncementsRead(ids: string[]): void {
const state = getAnnouncementState();
const newIds = ids.filter((id) => !state.readIds.includes(id));
if (newIds.length > 0) {
state.readIds.push(...newIds);
saveAnnouncementState(state);
}
}
/**
* Dismiss an announcement (hide it from the list)
*/
export function dismissAnnouncement(id: string): void {
const state = getAnnouncementState();
if (!state.dismissedIds.includes(id)) {
state.dismissedIds.push(id);
saveAnnouncementState(state);
}
}
/**
* Mark an important announcement as already toasted (shown as toast)
*/
export function markAnnouncementToasted(id: string): void {
const state = getAnnouncementState();
if (!state.toastedIds.includes(id)) {
state.toastedIds.push(id);
saveAnnouncementState(state);
}
}
/**
* Check if an announcement has been read
*/
export function isAnnouncementRead(id: string): boolean {
return getAnnouncementState().readIds.includes(id);
}
/**
* Check if an announcement has been toasted
*/
export function isAnnouncementToasted(id: string): boolean {
return getAnnouncementState().toastedIds.includes(id);
}
/**
* Check if an announcement has been dismissed
*/
export function isAnnouncementDismissed(id: string): boolean {
return getAnnouncementState().dismissedIds.includes(id);
}

View file

@ -57,6 +57,8 @@
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@streamdown/code": "^1.0.2",
"@streamdown/math": "^1.0.2",
"@tabler/icons-react": "^3.34.1",
"@tanstack/query-core": "^5.90.7",
"@tanstack/react-query": "^5.90.7",
@ -79,6 +81,7 @@
"geist": "^1.4.2",
"jotai": "^2.15.1",
"jotai-tanstack-query": "^0.11.0",
"katex": "^0.16.28",
"lucide-react": "^0.477.0",
"motion": "^12.23.22",
"next": "^16.1.0",
@ -101,7 +104,7 @@
"remark-gfm": "^4.0.1",
"server-only": "^0.0.1",
"sonner": "^2.0.6",
"streamdown": "^1.6.10",
"streamdown": "^2.2.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"unist-util-visit": "^5.0.0",

File diff suppressed because it is too large Load diff