refactor(ux): Enhance podcast and chat panel features

- Updated the welcome dialog in podcast generation to reflect the correct podcast title.
- Improved the Dashboard layout by adding an indicator for active chats on the researcher page.
- Enhanced the breadcrumb component to fetch and display chat details dynamically.
- Adjusted the chat panel width for better visibility.
- Introduced animations and improved user interactions in the chat panel and podcast player components.
- Updated the ConfigModal to provide clearer instructions for user input.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2025-11-11 18:07:32 -08:00
parent 0835a192a2
commit 3ccb0bc7bb
7 changed files with 390 additions and 146 deletions

View file

@ -99,10 +99,10 @@ async def create_merged_podcast_audio(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Generate audio for each transcript and merge them into a single podcast file.""" """Generate audio for each transcript and merge them into a single podcast file."""
configuration = Configuration.from_runnable_config(config) # configuration = Configuration.from_runnable_config(config)
starting_transcript = PodcastTranscriptEntry( starting_transcript = PodcastTranscriptEntry(
speaker_id=1, dialog=f"Welcome to {configuration.podcast_title} Podcast." speaker_id=1, dialog="Welcome to Surfsense Podcast."
) )
transcript = state.podcast_transcript transcript = state.podcast_transcript

View file

@ -1,7 +1,8 @@
"use client"; "use client";
import { useAtom } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { Loader2, PanelRight } from "lucide-react"; import { Loader2, PanelRight } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type React from "react"; import type React from "react";
@ -16,6 +17,7 @@ import { Separator } from "@/components/ui/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { useLLMPreferences } from "@/hooks/use-llm-configs"; import { useLLMPreferences } from "@/hooks/use-llm-configs";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { activeChatIdAtom } from "@/stores/chat/active-chat.atom";
import { chatUIAtom } from "@/stores/chat/chat-ui.atom"; import { chatUIAtom } from "@/stores/chat/chat-ui.atom";
export function DashboardClientLayout({ export function DashboardClientLayout({
@ -35,9 +37,26 @@ export function DashboardClientLayout({
const searchSpaceIdNum = Number(searchSpaceId); const searchSpaceIdNum = Number(searchSpaceId);
const [chatUIState, setChatUIState] = useAtom(chatUIAtom); const [chatUIState, setChatUIState] = useAtom(chatUIAtom);
const activeChatId = useAtomValue(activeChatIdAtom);
const [showIndicator, setShowIndicator] = useState(false);
const { isChatPannelOpen } = chatUIState; const { isChatPannelOpen } = chatUIState;
// Check if we're on the researcher page
const isResearcherPage = pathname?.includes("/researcher");
// Show indicator when chat becomes active and panel is closed
useEffect(() => {
if (activeChatId && !isChatPannelOpen) {
setShowIndicator(true);
// Hide indicator after 5 seconds
const timer = setTimeout(() => setShowIndicator(false), 5000);
return () => clearTimeout(timer);
} else {
setShowIndicator(false);
}
}, [activeChatId, isChatPannelOpen]);
const { loading, error, isOnboardingComplete } = useLLMPreferences(searchSpaceIdNum); const { loading, error, isOnboardingComplete } = useLLMPreferences(searchSpaceIdNum);
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false); const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);
@ -161,24 +180,112 @@ export function DashboardClientLayout({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LanguageSwitcher /> <LanguageSwitcher />
<ThemeTogglerComponent /> <ThemeTogglerComponent />
<button {/* Only show artifacts toggle on researcher page */}
type="button" {isResearcherPage && (
onClick={() => <motion.div
setChatUIState((prev) => ({ className="relative"
...prev, animate={
isChatPannelOpen: !isChatPannelOpen, showIndicator
})) ? {
} scale: [1, 1.05, 1],
className={cn(" shrink-0 rounded-full w-fit hover:bg-muted")} }
> : {}
<PanelRight className="h-4 w-4" /> }
</button> transition={{
duration: 2,
repeat: showIndicator ? Number.POSITIVE_INFINITY : 0,
ease: "easeInOut",
}}
>
<motion.button
type="button"
onClick={() => {
setChatUIState((prev) => ({
...prev,
isChatPannelOpen: !isChatPannelOpen,
}));
setShowIndicator(false);
}}
className={cn(
"shrink-0 rounded-full p-2 transition-all duration-300 relative",
showIndicator
? "bg-primary/20 hover:bg-primary/30 shadow-lg shadow-primary/25"
: "hover:bg-muted",
activeChatId && !showIndicator && "hover:bg-primary/10"
)}
title="Toggle Artifacts Panel"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<motion.div
animate={
showIndicator
? {
rotate: [0, -10, 10, -10, 0],
}
: {}
}
transition={{
duration: 0.5,
repeat: showIndicator ? Number.POSITIVE_INFINITY : 0,
repeatDelay: 2,
}}
>
<PanelRight
className={cn(
"h-4 w-4 transition-colors",
showIndicator && "text-primary"
)}
/>
</motion.div>
</motion.button>
{/* Pulsing indicator badge */}
<AnimatePresence>
{showIndicator && (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
className="absolute -right-1 -top-1 pointer-events-none"
>
<motion.div
animate={{
scale: [1, 1.3, 1],
}}
transition={{
duration: 1.5,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="relative"
>
<div className="h-2.5 w-2.5 rounded-full bg-primary shadow-lg" />
<motion.div
animate={{
scale: [1, 2.5, 1],
opacity: [0.6, 0, 0.6],
}}
transition={{
duration: 1.5,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute inset-0 h-2.5 w-2.5 rounded-full bg-primary"
/>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</div> </div>
</div> </div>
</header> </header>
<div className="grow flex-1 overflow-auto">{children}</div> <div className="grow flex-1 overflow-auto">{children}</div>
</div> </div>
<ChatPanelContainer /> {/* Only render chat panel on researcher page */}
{isResearcherPage && <ChatPanelContainer />}
</main> </main>
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>

View file

@ -43,7 +43,7 @@ export function ChatPanelContainer() {
<div <div
className={cn( className={cn(
"shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 flex flex-col h-full transition-all", "shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 flex flex-col h-full transition-all",
isChatPannelOpen ? "w-64" : "w-0" isChatPannelOpen ? "w-96" : "w-0"
)} )}
> >
{isChatLoading || chatError ? ( {isChatLoading || chatError ? (

View file

@ -1,7 +1,8 @@
"use client"; "use client";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { AlertCircle, Pencil, Play, Podcast, RefreshCw } from "lucide-react"; import { AlertCircle, Pencil, Play, Podcast, RefreshCw, Sparkles } from "lucide-react";
import { motion } from "motion/react";
import { useCallback, useContext, useTransition } from "react"; import { useCallback, useContext, useTransition } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { activeChatAtom } from "@/stores/chat/active-chat.atom"; import { activeChatAtom } from "@/stores/chat/active-chat.atom";
@ -41,23 +42,26 @@ export function ChatPanelView(props: ChatPanelViewProps) {
return ( return (
<div className="w-full"> <div className="w-full">
<div <div className={cn("w-full p-4", !isChatPannelOpen && "flex items-center justify-center")}>
className={cn(
"w-full cursor-pointer p-4 border-b",
!isChatPannelOpen && "flex items-center justify-center"
)}
title={podcastIsStale ? "Regenerate Podcast" : "Generate Podcast"}
>
{isChatPannelOpen ? ( {isChatPannelOpen ? (
<div className="space-y-3"> <div className="space-y-3">
{/* Show stale podcast warning if applicable */} {/* Show stale podcast warning if applicable */}
{podcastIsStale && ( {podcastIsStale && (
<div className="rounded-lg p-3 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800"> <motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-xl p-3 bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/20 border border-amber-200/50 dark:border-amber-800/50 shadow-sm"
>
<div className="flex gap-2 items-start"> <div className="flex gap-2 items-start">
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-500 mt-0.5 flex-shrink-0" /> <motion.div
<div className="text-sm text-amber-800 dark:text-amber-200"> animate={{ rotate: [0, 10, -10, 0] }}
<p className="font-medium">Podcast is outdated</p> transition={{ duration: 0.5, repeat: Infinity, repeatDelay: 3 }}
<p className="text-xs mt-1 opacity-90"> >
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
</motion.div>
<div className="text-sm text-amber-900 dark:text-amber-100">
<p className="font-semibold">Podcast Outdated</p>
<p className="text-xs mt-1 opacity-80">
{getPodcastStalenessMessage( {getPodcastStalenessMessage(
chatDetails?.state_version || 0, chatDetails?.state_version || 0,
podcast?.chat_state_version podcast?.chat_state_version
@ -65,41 +69,96 @@ export function ChatPanelView(props: ChatPanelViewProps) {
</p> </p>
</div> </div>
</div> </div>
</div> </motion.div>
)} )}
<div <motion.div
role="button" whileHover={{ scale: 1.02 }}
tabIndex={0} whileTap={{ scale: 0.98 }}
onClick={handleGeneratePost} initial={{ opacity: 0 }}
onKeyDown={(e) => { animate={{ opacity: 1 }}
if (e.key === "Enter") { transition={{ duration: 0.3 }}
e.preventDefault();
handleGeneratePost();
}
}}
className={cn(
"w-full space-y-3 rounded-xl p-3 transition-colors",
podcastIsStale
? "bg-gradient-to-r from-amber-400/50 to-orange-300/50 dark:from-amber-500/30 dark:to-orange-600/30 hover:from-amber-400/60 hover:to-orange-300/60"
: "bg-gradient-to-r from-slate-400/50 to-slate-200/50 dark:from-slate-400/30 dark:to-slate-800/60 hover:from-slate-400/60 hover:to-slate-200/60"
)}
> >
<div className="w-full flex items-center justify-between"> <div
{podcastIsStale ? ( role="button"
<RefreshCw strokeWidth={1} className="h-5 w-5" /> tabIndex={0}
) : ( onClick={handleGeneratePost}
<Podcast strokeWidth={1} className="h-5 w-5" /> onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleGeneratePost();
}
}}
className={cn(
"relative w-full rounded-2xl p-4 transition-all duration-300 cursor-pointer group overflow-hidden",
"border-2",
podcastIsStale
? "bg-gradient-to-br from-amber-500/10 via-orange-500/10 to-amber-500/10 dark:from-amber-500/20 dark:via-orange-500/20 dark:to-amber-500/20 border-amber-400/50 hover:border-amber-400 hover:shadow-lg hover:shadow-amber-500/20"
: "bg-gradient-to-br from-primary/10 via-primary/5 to-primary/10 border-primary/30 hover:border-primary/60 hover:shadow-lg hover:shadow-primary/20"
)} )}
<ConfigModal generatePodcast={generatePodcast} /> >
{/* Background gradient animation */}
<motion.div
className={cn(
"absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500",
podcastIsStale
? "bg-gradient-to-r from-transparent via-amber-400/10 to-transparent"
: "bg-gradient-to-r from-transparent via-primary/10 to-transparent"
)}
animate={{
x: ["-100%", "100%"],
}}
transition={{
duration: 3,
repeat: Infinity,
ease: "linear",
}}
/>
<div className="relative z-10 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<motion.div
className={cn(
"p-2.5 rounded-xl",
podcastIsStale
? "bg-amber-500/20 dark:bg-amber-500/30"
: "bg-primary/20 dark:bg-primary/30"
)}
animate={{
rotate: podcastIsStale ? [0, 360] : 0,
}}
transition={{
duration: 2,
repeat: podcastIsStale ? Infinity : 0,
ease: "linear",
}}
>
{podcastIsStale ? (
<RefreshCw className="h-5 w-5 text-amber-600 dark:text-amber-400" />
) : (
<Sparkles className="h-5 w-5 text-primary" />
)}
</motion.div>
<div>
<p className="text-sm font-semibold">
{podcastIsStale ? "Regenerate Podcast" : "Generate Podcast"}
</p>
<p className="text-xs text-muted-foreground">
{podcastIsStale
? "Update with latest changes"
: "Create podcasts of your chat"}
</p>
</div>
</div>
<ConfigModal generatePodcast={generatePodcast} />
</div>
</div>
</div> </div>
<p className="text-sm font-medium text-left"> </motion.div>
{podcastIsStale ? "Regenerate Podcast" : "Generate Podcast"}
</p>
</div>
</div> </div>
) : ( ) : (
<button <motion.button
title={podcastIsStale ? "Regenerate Podcast" : "Generate Podcast"} title={podcastIsStale ? "Regenerate Podcast" : "Generate Podcast"}
type="button" type="button"
onClick={() => onClick={() =>
@ -108,37 +167,39 @@ export function ChatPanelView(props: ChatPanelViewProps) {
isChatPannelOpen: !isChatPannelOpen, isChatPannelOpen: !isChatPannelOpen,
})) }))
} }
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className={cn( className={cn(
"p-2 rounded-full hover:bg-muted transition-colors", "p-2.5 rounded-full transition-colors shadow-sm",
podcastIsStale && "text-amber-600 dark:text-amber-500" podcastIsStale
? "bg-amber-500/20 hover:bg-amber-500/30 text-amber-600 dark:text-amber-400"
: "bg-primary/20 hover:bg-primary/30 text-primary"
)} )}
> >
{podcastIsStale ? ( {podcastIsStale ? <RefreshCw className="h-5 w-5" /> : <Sparkles className="h-5 w-5" />}
<RefreshCw strokeWidth={1} className="h-5 w-5" /> </motion.button>
) : (
<Podcast strokeWidth={1} className="h-5 w-5" />
)}
</button>
)} )}
</div> </div>
{podcast ? ( {podcast ? (
<div <div
className={cn( className={cn(
"w-full border-b", "w-full border-t",
!isChatPannelOpen && "flex items-center justify-center p-4" !isChatPannelOpen && "flex items-center justify-center p-4"
)} )}
> >
{isChatPannelOpen ? ( {isChatPannelOpen ? (
<PodcastPlayer compact podcast={podcast} /> <PodcastPlayer compact podcast={podcast} />
) : podcast ? ( ) : podcast ? (
<button <motion.button
title="Play Podcast" title="Play Podcast"
type="button" type="button"
onClick={() => setChatUIState((prev) => ({ ...prev, isChatPannelOpen: true }))} onClick={() => setChatUIState((prev) => ({ ...prev, isChatPannelOpen: true }))}
className="p-2 rounded-full hover:bg-muted transition-colors text-green-600 dark:text-green-500" whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="p-2.5 rounded-full bg-green-500/20 hover:bg-green-500/30 text-green-600 dark:text-green-400 transition-colors shadow-sm"
> >
<Play strokeWidth={1} className="h-5 w-5" /> <Play className="h-5 w-5" />
</button> </motion.button>
) : null} ) : null}
</div> </div>
) : null} ) : null}

View file

@ -44,8 +44,20 @@ export function ConfigModal(props: ConfigModalProps) {
<PopoverContent onClick={(e) => e.stopPropagation()} align="end" className="bg-sidebar w-96 "> <PopoverContent onClick={(e) => e.stopPropagation()} align="end" className="bg-sidebar w-96 ">
<form className="flex flex-col gap-3 w-full"> <form className="flex flex-col gap-3 w-full">
<label className="text-sm font-medium" htmlFor="prompt"> <label className="text-sm font-medium" htmlFor="prompt">
What subjects should the AI cover in this podcast ? Special user instructions
</label> </label>
<p className="text-xs text-slate-500 dark:text-slate-400">
Leave empty to use the default prompt
</p>
<div className="text-xs text-slate-500 dark:text-slate-400 space-y-1">
<p>Examples:</p>
<ul className="list-disc list-inside space-y-0.5">
<li>Make hosts speak in London street language</li>
<li>Use real-world analogies and metaphors</li>
<li>Add dramatic pauses like a late-night radio show</li>
<li>Include 90s pop culture references</li>
</ul>
</div>
<textarea <textarea
name="prompt" name="prompt"

View file

@ -202,94 +202,116 @@ export function PodcastPlayer({
if (compact) { if (compact) {
return ( return (
<> <>
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-4 p-4">
<div className="flex items-center gap-2"> {/* Audio Visualizer */}
<motion.div <motion.div
className="w-8 h-8 bg-primary/20 rounded-md flex items-center justify-center flex-shrink-0" className="relative h-1 bg-gradient-to-r from-primary/20 via-primary/40 to-primary/20 rounded-full overflow-hidden"
animate={{ scale: isPlaying ? [1, 1.05, 1] : 1 }} initial={{ opacity: 0 }}
transition={{ animate={{ opacity: 1 }}
repeat: isPlaying ? Infinity : 0, transition={{ duration: 0.5 }}
duration: 2, >
}} {isPlaying && (
> <motion.div
<Podcast className="h-4 w-4 text-primary" /> className="absolute inset-0 bg-gradient-to-r from-transparent via-primary to-transparent"
</motion.div> animate={{
<h4 className="font-medium text-xs line-clamp-1 flex-grow">{podcast.title}</h4> x: ["-100%", "100%"],
{onClose && ( }}
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}> transition={{
<Button duration: 2,
variant="ghost" repeat: Infinity,
size="icon" ease: "linear",
onClick={onClose} }}
className="h-6 w-6 flex-shrink-0" />
>
<X className="h-3 w-3" />
</Button>
</motion.div>
)} )}
</div> </motion.div>
<div className="flex items-center gap-1"> {/* Progress Bar with Time */}
<div className="space-y-2">
<Slider <Slider
value={[currentTime]} value={[currentTime]}
min={0} min={0}
max={duration || 100} max={duration || 100}
step={0.1} step={0.1}
onValueChange={handleSeek} onValueChange={handleSeek}
className="flex-grow" className="w-full cursor-pointer"
/> />
<div className="text-xs text-muted-foreground whitespace-nowrap flex-shrink-0"> <div className="flex items-center justify-between text-xs text-muted-foreground">
{formatTime(currentTime)} / {formatTime(duration)} <span className="font-mono">{formatTime(currentTime)}</span>
<span className="font-mono">{formatTime(duration)}</span>
</div> </div>
</div> </div>
<div className="flex items-center justify-between gap-1"> {/* Controls */}
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}> <div className="flex items-center justify-between">
<Button {/* Left: Volume */}
variant="ghost" <div className="flex items-center gap-2 flex-1">
size="icon" <motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
onClick={skipBackward} <Button variant="ghost" size="icon" onClick={toggleMute} className="h-8 w-8">
className="h-7 w-7" {isMuted ? (
disabled={!duration} <VolumeX className="h-4 w-4 text-muted-foreground" />
> ) : (
<SkipBack className="h-3 w-3" /> <Volume2 className="h-4 w-4" />
</Button> )}
</motion.div> </Button>
</motion.div>
</div>
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}> {/* Center: Playback Controls */}
<Button <div className="flex items-center gap-1">
variant="default" <motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
size="icon" <Button
onClick={togglePlayPause} variant="ghost"
className="h-8 w-8 rounded-full" size="icon"
disabled={!duration} onClick={skipBackward}
> className="h-9 w-9"
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />} disabled={!duration}
</Button> >
</motion.div> <SkipBack className="h-4 w-4" />
</Button>
</motion.div>
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}> <motion.div
<Button whileHover={{ scale: 1.05 }}
variant="ghost" whileTap={{ scale: 0.95 }}
size="icon" animate={
onClick={skipForward} isPlaying
className="h-7 w-7" ? {
disabled={!duration} boxShadow: [
"0 0 0 0 rgba(var(--primary), 0)",
"0 0 0 8px rgba(var(--primary), 0.1)",
"0 0 0 0 rgba(var(--primary), 0)",
],
}
: {}
}
transition={{ duration: 1.5, repeat: isPlaying ? Infinity : 0 }}
> >
<SkipForward className="h-3 w-3" /> <Button
</Button> variant="default"
</motion.div> size="icon"
onClick={togglePlayPause}
className="h-10 w-10 rounded-full"
disabled={!duration}
>
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5 ml-0.5" />}
</Button>
</motion.div>
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}> <motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={toggleMute} onClick={skipForward}
className={`h-7 w-7 ${isMuted ? "text-muted-foreground" : "text-primary"}`} className="h-9 w-9"
> disabled={!duration}
{isMuted ? <VolumeX className="h-3 w-3" /> : <Volume2 className="h-3 w-3" />} >
</Button> <SkipForward className="h-4 w-4" />
</motion.div> </Button>
</motion.div>
</div>
{/* Right: Placeholder for symmetry */}
<div className="flex-1" />
</div> </div>
</div> </div>

View file

@ -2,7 +2,8 @@
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import React from "react"; import React, { useEffect, useState } from "react";
import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client";
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@ -11,6 +12,8 @@ import {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb";
import { useSearchSpace } from "@/hooks/use-search-space";
import { fetchChatDetails } from "@/lib/apis/chat-apis";
interface BreadcrumbItemInterface { interface BreadcrumbItemInterface {
label: string; label: string;
@ -20,6 +23,31 @@ interface BreadcrumbItemInterface {
export function DashboardBreadcrumb() { export function DashboardBreadcrumb() {
const t = useTranslations("breadcrumb"); const t = useTranslations("breadcrumb");
const pathname = usePathname(); const pathname = usePathname();
const [chatDetails, setChatDetails] = useState<ChatDetails | null>(null);
// Extract search space ID and chat ID from pathname
const segments = pathname.split("/").filter(Boolean);
const searchSpaceId = segments[0] === "dashboard" && segments[1] ? segments[1] : null;
const chatId =
segments[0] === "dashboard" && segments[2] === "researcher" && segments[3] ? segments[3] : null;
// Fetch search space details if we have an ID
const { searchSpace } = useSearchSpace({
searchSpaceId: searchSpaceId || "",
autoFetch: !!searchSpaceId,
});
// Fetch chat details if we have a chat ID
useEffect(() => {
if (chatId) {
const token = localStorage.getItem("surfsense_bearer_token");
if (token) {
fetchChatDetails(chatId, token).then(setChatDetails);
}
} else {
setChatDetails(null);
}
}, [chatId]);
// Parse the pathname to create breadcrumb items // Parse the pathname to create breadcrumb items
const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => { const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => {
@ -31,8 +59,10 @@ export function DashboardBreadcrumb() {
// Handle search space // Handle search space
if (segments[0] === "dashboard" && segments[1]) { if (segments[0] === "dashboard" && segments[1]) {
// Use the actual search space name if available, otherwise fall back to the ID
const searchSpaceLabel = searchSpace?.name || `${t("search_space")} ${segments[1]}`;
breadcrumbs.push({ breadcrumbs.push({
label: `${t("search_space")} ${segments[1]}`, label: searchSpaceLabel,
href: `/dashboard/${segments[1]}`, href: `/dashboard/${segments[1]}`,
}); });
@ -92,6 +122,18 @@ export function DashboardBreadcrumb() {
return breadcrumbs; return breadcrumbs;
} }
// Handle researcher sub-sections (chat IDs)
if (section === "researcher") {
// Use the actual chat title if available, otherwise fall back to the ID
const chatLabel = chatDetails?.title || subSection;
breadcrumbs.push({
label: t("researcher"),
href: `/dashboard/${segments[1]}/researcher`,
});
breadcrumbs.push({ label: chatLabel });
return breadcrumbs;
}
// Handle connector sub-sections // Handle connector sub-sections
if (section === "connectors") { if (section === "connectors") {
// Handle specific connector types // Handle specific connector types