diff --git a/client/webui/frontend/src/AppLayout.tsx b/client/webui/frontend/src/AppLayout.tsx index 058ebabe7a..f64bcbd02b 100644 --- a/client/webui/frontend/src/AppLayout.tsx +++ b/client/webui/frontend/src/AppLayout.tsx @@ -1,18 +1,38 @@ -import { useEffect } from "react"; -import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { useEffect, useState, useCallback } from "react"; +import { Outlet } from "react-router-dom"; -import { NavigationSidebar, ToastContainer, bottomNavigationItems, getTopNavigationItems, EmptyState } from "@/lib/components"; +import { ToastContainer, EmptyState, NavigationSidebar } from "@/lib/components"; import { SelectionContextMenu, useTextSelection } from "@/lib/components/chat/selection"; import { ChatProvider } from "@/lib/providers"; -import { useAuthContext, useBeforeUnload, useConfigContext } from "@/lib/hooks"; +import { useAuthContext, useBeforeUnload } from "@/lib/hooks"; + +const NAV_COLLAPSED_STORAGE_KEY = "sam-nav-collapsed"; function AppLayoutContent() { - const location = useLocation(); - const navigate = useNavigate(); const { isAuthenticated, login, useAuthorization } = useAuthContext(); - const { configFeatureEnablement } = useConfigContext(); const { isMenuOpen, menuPosition, selectedText, clearSelection } = useTextSelection(); + // Initialize from localStorage, default to expanded (false) + const [isNavCollapsed, setIsNavCollapsed] = useState(() => { + if (typeof window !== "undefined") { + const stored = localStorage.getItem(NAV_COLLAPSED_STORAGE_KEY); + // If stored value exists, use it; otherwise default to expanded (false) + return stored !== null ? stored === "true" : false; + } + return false; // Default to expanded + }); + + const handleNavToggle = useCallback(() => { + setIsNavCollapsed(prev => { + const newValue = !prev; + // Persist to localStorage + if (typeof window !== "undefined") { + localStorage.setItem(NAV_COLLAPSED_STORAGE_KEY, String(newValue)); + } + return newValue; + }); + }, []); + // Temporary fix: Radix dialogs sometimes leave pointer-events: none on body when closed useEffect(() => { const observer = new MutationObserver(() => { @@ -38,21 +58,9 @@ function AppLayoutContent() { }; }, []); - // Get navigation items based on feature flags - const topNavItems = getTopNavigationItems(configFeatureEnablement); - // Enable beforeunload warning when chat data is present useBeforeUnload(); - const getActiveItem = () => { - const path = location.pathname; - if (path === "/" || path.startsWith("/chat")) return "chat"; - if (path.startsWith("/projects")) return "projects"; - if (path.startsWith("/prompts")) return "prompts"; - if (path.startsWith("/agents")) return "agentMesh"; - return "chat"; - }; - if (useAuthorization && !isAuthenticated) { return ( { - const item = topNavItems.find(item => item.id === itemId) || bottomNavigationItems.find(item => item.id === itemId); - - if (item?.onClick && itemId !== "settings") { - item.onClick(); - } else if (itemId !== "settings") { - navigate(`/${itemId === "agentMesh" ? "agents" : itemId}`); - } - }; - - const handleHeaderClick = () => { - navigate("/chat"); - }; - return ( -
- +
+ {/* Navigation Sidebar - Persistent across all pages */} +
diff --git a/client/webui/frontend/src/assets/solace-logo-full.svg b/client/webui/frontend/src/assets/solace-logo-full.svg new file mode 100644 index 0000000000..2d1ed2f9c3 --- /dev/null +++ b/client/webui/frontend/src/assets/solace-logo-full.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/client/webui/frontend/src/assets/solace-logo-s.svg b/client/webui/frontend/src/assets/solace-logo-s.svg new file mode 100644 index 0000000000..30723886d6 --- /dev/null +++ b/client/webui/frontend/src/assets/solace-logo-s.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/webui/frontend/src/lib/components/chat/LegacyChatSessions.tsx b/client/webui/frontend/src/lib/components/chat/LegacyChatSessions.tsx new file mode 100644 index 0000000000..020067102c --- /dev/null +++ b/client/webui/frontend/src/lib/components/chat/LegacyChatSessions.tsx @@ -0,0 +1,27 @@ +import { LegacySessionList } from "./LegacySessionList"; +import { useConfigContext, useChatContext } from "@/lib/hooks"; +import { useProjectContext } from "@/lib/providers"; + +export const LegacyChatSessions = () => { + const { persistenceEnabled } = useConfigContext(); + const { sessionName } = useChatContext(); + const { projects } = useProjectContext(); + + if (persistenceEnabled) return ; + + // When persistence is disabled, show simple single-session view like in main + return ( +
+
+ {/* Current Session */} +
+
{sessionName || "New Chat"}
+
Current session
+
+ + {/* Multi-session notice */} +
Persistence is not enabled.
+
+
+ ); +}; diff --git a/client/webui/frontend/src/lib/components/chat/LegacySessionList.tsx b/client/webui/frontend/src/lib/components/chat/LegacySessionList.tsx new file mode 100644 index 0000000000..37cd1442c7 --- /dev/null +++ b/client/webui/frontend/src/lib/components/chat/LegacySessionList.tsx @@ -0,0 +1,616 @@ +import React, { useEffect, useState, useRef, useCallback, useMemo } from "react"; +import { useInView } from "react-intersection-observer"; +import { useNavigate } from "react-router-dom"; + +import { Trash2, Check, X, Pencil, MessageCircle, FolderInput, MoreHorizontal, PanelsTopLeft, Sparkles, Loader2 } from "lucide-react"; + +import { api } from "@/lib/api"; +import { useChatContext, useConfigContext, useTitleGeneration, useTitleAnimation } from "@/lib/hooks"; +import type { Project, Session } from "@/lib/types"; + +interface SessionNameProps { + session: Session; + respondingSessionId: string | null; +} + +const SessionName: React.FC = ({ session, respondingSessionId }) => { + const { autoTitleGenerationEnabled } = useConfigContext(); + + const displayName = useMemo(() => { + if (session.name && session.name.trim()) { + return session.name; + } + // Fallback to "New Chat" if no name + return "New Chat"; + }, [session.name]); + + // Pass session ID to useTitleAnimation so it can listen for title generation events + const { text: animatedName, isAnimating, isGenerating } = useTitleAnimation(displayName, session.id); + + const isWaitingForTitle = useMemo(() => { + // Always show pulse when isGenerating (manual "Rename with AI") + if (isGenerating) { + return true; + } + + if (!autoTitleGenerationEnabled) { + return false; // No pulse when auto title generation is disabled + } + const isNewChat = !session.name || session.name === "New Chat"; + // Pulse if this session is the one that started the response + const isThisSessionResponding = respondingSessionId === session.id; + // Also pulse if this session has a running background task and no title yet + // This handles the case where user switched away while task is running + const hasBackgroundTaskWithNewTitle = session.hasRunningBackgroundTask && isNewChat; + return (isThisSessionResponding && isNewChat) || hasBackgroundTaskWithNewTitle; + }, [session.name, session.id, respondingSessionId, isGenerating, autoTitleGenerationEnabled, session.hasRunningBackgroundTask]); + + // Show slow pulse while waiting for title, faster pulse during transition animation + const animationClass = useMemo(() => { + if (isGenerating || isAnimating) { + if (isWaitingForTitle) { + return "animate-pulse-slow"; + } + return "animate-pulse opacity-50"; + } + // For automatic title generation waiting state + if (isWaitingForTitle) { + return "animate-pulse-slow"; + } + return "opacity-100"; + }, [isWaitingForTitle, isAnimating, isGenerating]); + + return {animatedName}; +}; +import { formatTimestamp, getErrorMessage } from "@/lib/utils"; +import { MoveSessionDialog, ProjectBadge, SessionSearch } from "@/lib/components/chat"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Spinner, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/lib/components/ui"; + +export interface PaginatedSessionsResponse { + data: Session[]; + meta: { + pagination: { + pageNumber: number; + count: number; + pageSize: number; + nextPage: number | null; + totalPages: number; + }; + }; +} + +interface SessionListProps { + projects?: Project[]; +} + +export const LegacySessionList: React.FC = ({ projects = [] }) => { + const navigate = useNavigate(); + const { sessionId, handleSwitchSession, updateSessionName, openSessionDeleteModal, addNotification, displayError, currentTaskId } = useChatContext(); + const { persistenceEnabled } = useConfigContext(); + const { generateTitle } = useTitleGeneration(); + const inputRef = useRef(null); + + // Track which session started the response to avoid pulsing the wrong session + // We use a Map to track task ID -> session ID relationships, plus a version counter to trigger re-renders + const taskToSessionRef = useRef>(new Map()); + const [taskMapVersion, setTaskMapVersion] = useState(0); + + // When a new task starts, remember which session it belongs to + // Don't rely on currentTaskId changes during session switches + useEffect(() => { + if (currentTaskId && !taskToSessionRef.current.has(currentTaskId)) { + // This is a genuinely new task - capture which session it started in + taskToSessionRef.current.set(currentTaskId, sessionId); + // Trigger a re-render so respondingSessionId useMemo recomputes + setTaskMapVersion(v => v + 1); + } + }, [currentTaskId, sessionId]); + + // Derive respondingSessionId from the current task's owning session + const respondingSessionId = useMemo(() => { + if (!currentTaskId) return null; + return taskToSessionRef.current.get(currentTaskId) || null; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentTaskId, taskMapVersion]); + + const [sessions, setSessions] = useState([]); + const [editingSessionId, setEditingSessionId] = useState(null); + const [editingSessionName, setEditingSessionName] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const [selectedProject, setSelectedProject] = useState("all"); + const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false); + const [sessionToMove, setSessionToMove] = useState(null); + const [regeneratingTitleForSession, setRegeneratingTitleForSession] = useState(null); + + const { ref: loadMoreRef, inView } = useInView({ + threshold: 0, + triggerOnce: false, + }); + + const fetchSessions = useCallback(async (pageNumber: number = 1, append: boolean = false) => { + setIsLoading(true); + + try { + const result: PaginatedSessionsResponse = await api.webui.get(`/api/v1/sessions?pageNumber=${pageNumber}&pageSize=20`); + + if (append) { + setSessions(prev => [...prev, ...result.data]); + } else { + setSessions(result.data); + } + + // Use metadata to determine if there are more pages + setHasMore(result.meta.pagination.nextPage !== null); + setCurrentPage(pageNumber); + } catch (error) { + console.error("An error occurred while fetching sessions:", error); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchSessions(1, false); + const handleNewSession = () => { + fetchSessions(1, false); + }; + const handleSessionUpdated = (event: CustomEvent) => { + const { sessionId } = event.detail; + setSessions(prevSessions => { + const updatedSession = prevSessions.find(s => s.id === sessionId); + if (updatedSession) { + const otherSessions = prevSessions.filter(s => s.id !== sessionId); + return [updatedSession, ...otherSessions]; + } + return prevSessions; + }); + }; + const handleTitleUpdated = async (event: Event) => { + const customEvent = event as CustomEvent; + const { sessionId: updatedSessionId } = customEvent.detail; + + // Fetch the updated session from backend to get the new title + try { + const sessionData = await api.webui.get(`/api/v1/sessions/${updatedSessionId}`); + const updatedSession = sessionData?.data; + + if (updatedSession) { + setSessions(prevSessions => { + return prevSessions.map(s => (s.id === updatedSessionId ? { ...s, name: updatedSession.name } : s)); + }); + } + } catch (error) { + console.error("[SessionList] Error fetching updated session:", error); + // Fallback: just refresh the entire list + fetchSessions(1, false); + } + }; + const handleBackgroundTaskCompleted = () => { + // Refresh session list when background task completes to update indicators + fetchSessions(1, false); + }; + window.addEventListener("new-chat-session", handleNewSession); + window.addEventListener("session-updated", handleSessionUpdated as EventListener); + window.addEventListener("session-title-updated", handleTitleUpdated); + window.addEventListener("background-task-completed", handleBackgroundTaskCompleted); + return () => { + window.removeEventListener("new-chat-session", handleNewSession); + window.removeEventListener("session-updated", handleSessionUpdated as EventListener); + window.removeEventListener("session-title-updated", handleTitleUpdated); + window.removeEventListener("background-task-completed", handleBackgroundTaskCompleted); + }; + }, [fetchSessions]); + + // Periodic refresh when there are sessions with running background tasks + // This is necessary to detect task completion when user is on a different session + useEffect(() => { + const hasBackgroundTasks = sessions.some(s => s.hasRunningBackgroundTask); + + if (!hasBackgroundTasks) { + return; // No background tasks, no need to poll + } + + const intervalId = setInterval(() => { + fetchSessions(1, false); + }, 10000); // Check every 10 seconds + + return () => { + clearInterval(intervalId); + }; + }, [sessions, fetchSessions]); + + useEffect(() => { + if (inView && hasMore && !isLoading) { + fetchSessions(currentPage + 1, true); + } + }, [inView, hasMore, isLoading, currentPage, fetchSessions]); + + useEffect(() => { + if (editingSessionId && inputRef.current) { + inputRef.current.focus(); + } + }, [editingSessionId]); + + const handleSessionClick = async (sessionId: string) => { + if (editingSessionId !== sessionId) { + await handleSwitchSession(sessionId); + } + }; + + const handleEditClick = (session: Session) => { + setEditingSessionId(session.id); + setEditingSessionName(session.name || ""); + }; + + const handleRename = async () => { + if (editingSessionId) { + const sessionIdToUpdate = editingSessionId; + const newName = editingSessionName; + + // Clear editing state + setEditingSessionId(null); + + // Update backend (this will trigger new-chat-session event which refetches) + await updateSessionName(sessionIdToUpdate, newName); + } + }; + + const handleDeleteClick = (session: Session) => { + openSessionDeleteModal(session); + }; + + const handleMoveClick = (session: Session) => { + setSessionToMove(session); + setIsMoveDialogOpen(true); + }; + const handleGoToProject = (session: Session) => { + if (!session.projectId) return; + + // Navigate to projects page with the project ID + navigate(`/projects/${session.projectId}`); + }; + + const handleRenameWithAI = useCallback( + async (session: Session) => { + if (regeneratingTitleForSession) { + addNotification?.("AI rename already in progress", "info"); + return; + } + + setRegeneratingTitleForSession(session.id); + + try { + // Fetch all tasks/messages for this session + const data = await api.webui.get(`/api/v1/sessions/${session.id}/chat-tasks`); + const tasks = data.tasks || []; + + if (tasks.length === 0) { + addNotification?.("No messages found in this session", "warning"); + setRegeneratingTitleForSession(null); + return; + } + + // Parse and extract all messages from all tasks + const allMessages: string[] = []; + + for (const task of tasks) { + const messageBubbles = JSON.parse(task.messageBubbles); + for (const bubble of messageBubbles) { + const text = bubble.text || ""; + if (text.trim()) { + allMessages.push(text.trim()); + } + } + } + + if (allMessages.length === 0) { + addNotification?.("No text content found in session", "warning"); + setRegeneratingTitleForSession(null); + return; + } + + // Create a summary of the conversation for better context + // Use LAST 3 messages of each type to capture recent conversation + const userMessages = allMessages.filter((_, idx) => idx % 2 === 0); // Assuming alternating user/agent + const agentMessages = allMessages.filter((_, idx) => idx % 2 === 1); + + const userSummary = userMessages.slice(-3).join(" | "); + const agentSummary = agentMessages.slice(-3).join(" | "); + + // Call the title generation service with the full context + // Pass current title so polling can detect the change + // Pass force=true to bypass the "already has title" check + await generateTitle(session.id, userSummary, agentSummary, session.name || "New Chat", true); + } catch (error) { + console.error("Error regenerating title:", error); + addNotification?.(`Failed to regenerate title: ${error instanceof Error ? error.message : "Unknown error"}`, "warning"); + } finally { + setRegeneratingTitleForSession(null); + } + }, + [generateTitle, addNotification, regeneratingTitleForSession] + ); + + const handleMoveConfirm = async (targetProjectId: string | null) => { + if (!sessionToMove) return; + + try { + await api.webui.patch(`/api/v1/sessions/${sessionToMove.id}/project`, { projectId: targetProjectId }); + + // Update local state + setSessions(prevSessions => + prevSessions.map(s => + s.id === sessionToMove.id + ? { + ...s, + projectId: targetProjectId, + projectName: targetProjectId ? projects.find(p => p.id === targetProjectId)?.name || null : null, + } + : s + ) + ); + + // Dispatch event to notify other components (like ProjectChatsSection) to refresh + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("session-moved", { + detail: { + sessionId: sessionToMove.id, + projectId: targetProjectId, + }, + }) + ); + } + + addNotification?.("Session moved successfully", "success"); + setIsMoveDialogOpen(false); + setSessionToMove(null); + } catch (error) { + displayError({ title: "Failed to Move Session", error: getErrorMessage(error, "An unknown error occurred while moving the session.") }); + } + }; + + const formatSessionDate = (dateString: string) => { + return formatTimestamp(dateString); + }; + + // Get unique project names from sessions, sorted alphabetically + const projectNames = useMemo(() => { + const uniqueProjectNames = new Set(); + let hasUnassignedChats = false; + + sessions.forEach(session => { + if (session.projectName) { + uniqueProjectNames.add(session.projectName); + } else { + hasUnassignedChats = true; + } + }); + + const sortedNames = Array.from(uniqueProjectNames).sort((a, b) => a.localeCompare(b)); + + if (hasUnassignedChats) { + sortedNames.unshift("(No Project)"); + } + + return sortedNames; + }, [sessions]); + + // Filter sessions by selected project + const filteredSessions = useMemo(() => { + if (selectedProject === "all") { + return sessions; + } + if (selectedProject === "(No Project)") { + return sessions.filter(session => !session.projectName); + } + return sessions.filter(session => session.projectName === selectedProject); + }, [sessions, selectedProject]); + + // Get the project ID for the selected project name (for search filtering) + const selectedProjectId = useMemo(() => { + if (selectedProject === "all") return null; + const project = projects.find(p => p.name === selectedProject); + return project?.id || null; + }, [selectedProject, projects]); + + return ( +
+
+ {/* Session Search */} +
+ +
+ + {/* Project Filter - Only show when persistence is enabled */} + {persistenceEnabled && projectNames.length > 0 && ( +
+ + +
+ )} +
+ +
+ {filteredSessions.length > 0 && ( +
    + {filteredSessions.map(session => ( +
  • +
    + {editingSessionId === session.id ? ( + setEditingSessionName(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter") { + e.preventDefault(); + handleRename(); + } + }} + className="min-w-0 flex-1 bg-transparent focus:outline-none" + /> + ) : ( + + )} +
    + {editingSessionId === session.id ? ( + <> + + + + ) : ( + + + + + + {session.projectId && ( + <> + { + e.stopPropagation(); + handleGoToProject(session); + }} + > + + Go to Project + + + + )} + { + e.stopPropagation(); + handleEditClick(session); + }} + > + + Rename + + { + e.stopPropagation(); + handleRenameWithAI(session); + }} + disabled={regeneratingTitleForSession === session.id} + > + + Rename with AI + + { + e.stopPropagation(); + handleMoveClick(session); + }} + > + + Move to Project + + + { + e.stopPropagation(); + handleDeleteClick(session); + }} + > + + Delete + + + + )} +
    +
    +
  • + ))} +
+ )} + {filteredSessions.length === 0 && sessions.length > 0 && !isLoading && ( +
+ + No sessions found for this project +
+ )} + {sessions.length === 0 && !isLoading && ( +
+ + No chat sessions available +
+ )} + {hasMore && ( +
+ {isLoading && } +
+ )} +
+ + { + setIsMoveDialogOpen(false); + setSessionToMove(null); + }} + onConfirm={handleMoveConfirm} + session={sessionToMove} + projects={projects} + currentProjectId={sessionToMove?.projectId} + /> +
+ ); +}; diff --git a/client/webui/frontend/src/lib/components/chat/LegacySessionSidePanel.tsx b/client/webui/frontend/src/lib/components/chat/LegacySessionSidePanel.tsx new file mode 100644 index 0000000000..bebcad9fd1 --- /dev/null +++ b/client/webui/frontend/src/lib/components/chat/LegacySessionSidePanel.tsx @@ -0,0 +1,40 @@ +import React from "react"; + +import { PanelLeftIcon } from "lucide-react"; + +import { Button } from "@/lib/components/ui"; + +import { LegacyChatSessions } from "./LegacyChatSessions"; +import { ChatSessionDialog } from "./ChatSessionDialog"; + +interface LegacySessionSidePanelProps { + onToggle: () => void; +} + +/** + * Legacy Session Side Panel - Simple chat sessions list from main branch + * Used when newNavigation feature flag is false + */ +export const LegacySessionSidePanel: React.FC = ({ onToggle }) => { + return ( +
+
+
+ +
Chats
+
+ +
+ +
+
+ + {/* Chat Sessions */} +
+ +
+
+ ); +}; diff --git a/client/webui/frontend/src/lib/components/chat/RecentChatsList.tsx b/client/webui/frontend/src/lib/components/chat/RecentChatsList.tsx new file mode 100644 index 0000000000..c8e047e8b8 --- /dev/null +++ b/client/webui/frontend/src/lib/components/chat/RecentChatsList.tsx @@ -0,0 +1,201 @@ +import React, { useEffect, useState, useCallback, useMemo, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import { MessageCircle, Loader2 } from "lucide-react"; + +import { api } from "@/lib/api"; +import { MAX_RECENT_CHATS } from "@/lib/constants/ui"; +import { useChatContext, useConfigContext, useTitleAnimation } from "@/lib/hooks"; +import { cn } from "@/lib/utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/lib/components/ui"; +import type { Session } from "@/lib/types"; + +interface SessionNameProps { + session: Session; + respondingSessionId: string | null; + isActive: boolean; +} + +const SessionName: React.FC = ({ session, respondingSessionId, isActive }) => { + const { autoTitleGenerationEnabled } = useConfigContext(); + + const displayName = useMemo(() => { + if (session.name && session.name.trim()) { + return session.name; + } + return "New Chat"; + }, [session.name]); + + const { text: animatedName, isAnimating, isGenerating } = useTitleAnimation(displayName, session.id); + + const isWaitingForTitle = useMemo(() => { + if (isGenerating) { + return true; + } + + if (!autoTitleGenerationEnabled) { + return false; + } + const isNewChat = !session.name || session.name === "New Chat"; + const isThisSessionResponding = respondingSessionId === session.id; + return isThisSessionResponding && isNewChat; + }, [session.name, session.id, respondingSessionId, isGenerating, autoTitleGenerationEnabled]); + + const animationClass = useMemo(() => { + if (isGenerating || isAnimating) { + if (isWaitingForTitle) { + return "animate-pulse-slow"; + } + return "animate-pulse opacity-50"; + } + if (isWaitingForTitle) { + return "animate-pulse-slow"; + } + return "opacity-100"; + }, [isWaitingForTitle, isAnimating, isGenerating]); + + return {animatedName}; +}; + +interface RecentChatsListProps { + maxItems?: number; +} + +export const RecentChatsList: React.FC = ({ maxItems = MAX_RECENT_CHATS }) => { + const navigate = useNavigate(); + const { sessionId, handleSwitchSession, currentTaskId } = useChatContext(); + const { persistenceEnabled } = useConfigContext(); + + // Track which session started the response + const taskToSessionRef = useRef>(new Map()); + const [taskMapVersion, setTaskMapVersion] = useState(0); + + useEffect(() => { + if (currentTaskId && !taskToSessionRef.current.has(currentTaskId)) { + taskToSessionRef.current.set(currentTaskId, sessionId); + setTaskMapVersion(v => v + 1); + } + }, [currentTaskId, sessionId]); + + const respondingSessionId = useMemo(() => { + if (!currentTaskId) return null; + return taskToSessionRef.current.get(currentTaskId) || null; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentTaskId, taskMapVersion]); + + const [sessions, setSessions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const fetchSessions = useCallback(async () => { + setIsLoading(true); + try { + const result = await api.webui.get(`/api/v1/sessions?pageNumber=1&pageSize=${maxItems}`); + setSessions(result.data || []); + } catch (error) { + console.error("An error occurred while fetching sessions:", error); + } finally { + setIsLoading(false); + } + }, [maxItems]); + + useEffect(() => { + if (persistenceEnabled) { + fetchSessions(); + } + + const handleNewSession = () => { + fetchSessions(); + }; + const handleSessionUpdated = (event: CustomEvent) => { + const { sessionId: updatedSessionId } = event.detail; + setSessions(prevSessions => { + const updatedSession = prevSessions.find(s => s.id === updatedSessionId); + if (updatedSession) { + const otherSessions = prevSessions.filter(s => s.id !== updatedSessionId); + return [updatedSession, ...otherSessions]; + } + return prevSessions; + }); + }; + const handleTitleUpdated = async (event: Event) => { + const customEvent = event as CustomEvent; + const { sessionId: updatedSessionId } = customEvent.detail; + + try { + const sessionData = await api.webui.get(`/api/v1/sessions/${updatedSessionId}`); + const updatedSession = sessionData?.data; + + if (updatedSession) { + setSessions(prevSessions => { + return prevSessions.map(s => (s.id === updatedSessionId ? { ...s, name: updatedSession.name } : s)); + }); + } + } catch (error) { + console.error("[RecentChatsList] Error fetching updated session:", error); + fetchSessions(); + } + }; + + window.addEventListener("new-chat-session", handleNewSession); + window.addEventListener("session-updated", handleSessionUpdated as EventListener); + window.addEventListener("session-title-updated", handleTitleUpdated); + + return () => { + window.removeEventListener("new-chat-session", handleNewSession); + window.removeEventListener("session-updated", handleSessionUpdated as EventListener); + window.removeEventListener("session-title-updated", handleTitleUpdated); + }; + }, [fetchSessions, persistenceEnabled]); + + const handleSessionClick = async (clickedSessionId: string) => { + // Navigate to chat page first, then switch session + navigate("/chat"); + await handleSwitchSession(clickedSessionId); + }; + + if (!persistenceEnabled) { + return
Persistence is not enabled.
; + } + + if (isLoading && sessions.length === 0) { + return ( +
+ +
+ ); + } + + if (sessions.length === 0) { + return ( +
+ + No recent chats +
+ ); + } + + return ( +
+ {sessions.slice(0, maxItems).map(session => ( + + ))} +
+ ); +}; diff --git a/client/webui/frontend/src/lib/components/chat/SessionList.tsx b/client/webui/frontend/src/lib/components/chat/SessionList.tsx index 2182d7927c..cbb46cf405 100644 --- a/client/webui/frontend/src/lib/components/chat/SessionList.tsx +++ b/client/webui/frontend/src/lib/components/chat/SessionList.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState, useRef, useCallback, useMemo } from "react" import { useInView } from "react-intersection-observer"; import { useNavigate } from "react-router-dom"; -import { Trash2, Check, X, Pencil, MessageCircle, FolderInput, MoreHorizontal, PanelsTopLeft, Sparkles, Loader2 } from "lucide-react"; +import { Trash2, Check, X, Pencil, MessageCircle, FolderInput, MoreHorizontal, PanelsTopLeft, Sparkles, Loader2, Search } from "lucide-react"; import { api } from "@/lib/api"; import { useChatContext, useConfigContext, useTitleGeneration, useTitleAnimation } from "@/lib/hooks"; @@ -63,7 +63,8 @@ const SessionName: React.FC = ({ session, respondingSessionId return {animatedName}; }; import { formatTimestamp, getErrorMessage } from "@/lib/utils"; -import { MoveSessionDialog, ProjectBadge, SessionSearch } from "@/lib/components/chat"; +import { MoveSessionDialog, ProjectBadge } from "@/lib/components/chat"; +import { Input } from "@/lib/components/ui"; import { Button, DropdownMenu, @@ -136,6 +137,7 @@ export const SessionList: React.FC = ({ projects = [] }) => { const [hasMore, setHasMore] = useState(true); const [isLoading, setIsLoading] = useState(false); const [selectedProject, setSelectedProject] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false); const [sessionToMove, setSessionToMove] = useState(null); const [regeneratingTitleForSession, setRegeneratingTitleForSession] = useState(null); @@ -249,9 +251,11 @@ export const SessionList: React.FC = ({ projects = [] }) => { } }, [editingSessionId]); - const handleSessionClick = async (sessionId: string) => { - if (editingSessionId !== sessionId) { - await handleSwitchSession(sessionId); + const handleSessionClick = async (clickedSessionId: string) => { + if (editingSessionId !== clickedSessionId) { + // Navigate to chat page first, then switch session + navigate("/chat"); + await handleSwitchSession(clickedSessionId); } }; @@ -414,38 +418,47 @@ export const SessionList: React.FC = ({ projects = [] }) => { return sortedNames; }, [sessions]); - // Filter sessions by selected project + // Filter sessions by selected project and search query const filteredSessions = useMemo(() => { - if (selectedProject === "all") { - return sessions; + let filtered = sessions; + + // Filter by project + if (selectedProject !== "all") { + if (selectedProject === "(No Project)") { + filtered = filtered.filter(session => !session.projectName); + } else { + filtered = filtered.filter(session => session.projectName === selectedProject); + } } - if (selectedProject === "(No Project)") { - return sessions.filter(session => !session.projectName); + + // Filter by search query + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase().trim(); + filtered = filtered.filter(session => { + const name = session.name?.toLowerCase() || "new chat"; + return name.includes(query); + }); } - return sessions.filter(session => session.projectName === selectedProject); - }, [sessions, selectedProject]); - // Get the project ID for the selected project name (for search filtering) - const selectedProjectId = useMemo(() => { - if (selectedProject === "all") return null; - const project = projects.find(p => p.name === selectedProject); - return project?.id || null; - }, [selectedProject, projects]); + return filtered; + }, [sessions, selectedProject, searchQuery]); return (
-
- {/* Session Search */} -
- + {/* Search and Project Filter on same line */} +
+ {/* Search Input */} +
+ + setSearchQuery(e.target.value)} className="pl-9" />
{/* Project Filter - Only show when persistence is enabled */} {persistenceEnabled && projectNames.length > 0 && ( -
- +
+ = ({ projects = [] }) => { /> ) : ( )} diff --git a/client/webui/frontend/src/lib/components/chat/SessionSidePanel.tsx b/client/webui/frontend/src/lib/components/chat/SessionSidePanel.tsx index 1b1060cf75..acee4a561d 100644 --- a/client/webui/frontend/src/lib/components/chat/SessionSidePanel.tsx +++ b/client/webui/frontend/src/lib/components/chat/SessionSidePanel.tsx @@ -1,36 +1,466 @@ -import React from "react"; +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; -import { PanelLeftIcon } from "lucide-react"; +import { Plus, Bot, FolderOpen, BookOpenText, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Bell, User, LayoutGrid, Settings, LogOut } from "lucide-react"; -import { Button } from "@/lib/components/ui"; +import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@/lib/components/ui"; +import { useChatContext, useConfigContext, useAuthContext } from "@/lib/hooks"; +import { useProjectContext } from "@/lib/providers"; +import { SolaceIcon } from "@/lib/components/common/SolaceIcon"; +import { MoveSessionDialog } from "@/lib/components/chat/MoveSessionDialog"; +import { SettingsDialog } from "@/lib/components/settings/SettingsDialog"; +import { api } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import { MAX_RECENT_CHATS } from "@/lib/constants/ui"; +import type { Session } from "@/lib/types"; -import { ChatSessions } from "./ChatSessions"; -import { ChatSessionDialog } from "./ChatSessionDialog"; +import { RecentChatsList } from "./RecentChatsList"; + +interface NavItem { + id: string; + label: string; + icon: React.ElementType; + onClick?: () => void; + badge?: string; + tooltip?: string; + hasSubmenu?: boolean; + children?: NavItem[]; +} + +// Navigation item button component - uses dark theme colors always +const NavItemButton: React.FC<{ + item: NavItem; + isActive: boolean; + onClick: () => void; + isExpanded?: boolean; + onToggleExpand?: () => void; + className?: string; + indent?: boolean; + hasActiveChild?: boolean; +}> = ({ item, isActive, onClick, isExpanded, onToggleExpand, className, indent, hasActiveChild }) => { + const buttonContent = ( + + ); + + if (item.tooltip) { + return ( + + {buttonContent} + +

{item.tooltip}

+
+
+ ); + } + + return buttonContent; +}; interface SessionSidePanelProps { onToggle: () => void; + isCollapsed?: boolean; + onNavigate?: (page: string) => void; } -export const SessionSidePanel: React.FC = ({ onToggle }) => { +export const SessionSidePanel: React.FC = ({ onToggle, isCollapsed = false, onNavigate }) => { + const { logout } = useAuthContext(); + const navigate = useNavigate(); + const location = useLocation(); + const [activeItem, setActiveItem] = useState("chats"); + const [expandedMenus, setExpandedMenus] = useState>({ + assets: false, + systemManagement: false, + }); + const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false); + const [sessionToMove, setSessionToMove] = useState(null); + const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false); + const { handleNewSession, addNotification } = useChatContext(); + const { configUseAuthorization, configFeatureEnablement } = useConfigContext(); + const { projects } = useProjectContext(); + + // Feature flags + const projectsEnabled = configFeatureEnablement?.projects ?? false; + const logoutEnabled = configUseAuthorization && configFeatureEnablement?.logout ? true : false; + + // Sync active item with current route + useEffect(() => { + const path = location.pathname; + if (path.startsWith("/agents")) { + setActiveItem("agents"); + } else if (path.startsWith("/projects")) { + setActiveItem("projects"); + } else if (path.startsWith("/prompts")) { + setActiveItem("prompts"); + // Auto-expand Assets menu when on prompts page + setExpandedMenus(prev => ({ ...prev, assets: true })); + } else if (path.startsWith("/artifacts")) { + setActiveItem("artifacts"); + // Auto-expand Assets menu when on artifacts page + setExpandedMenus(prev => ({ ...prev, assets: true })); + } else { + setActiveItem("chats"); + } + }, [location.pathname]); + + // Handle move session dialog event + const handleOpenMoveDialog = useCallback((event: CustomEvent<{ session: Session }>) => { + setSessionToMove(event.detail.session); + setIsMoveDialogOpen(true); + }, []); + + useEffect(() => { + window.addEventListener("open-move-session-dialog", handleOpenMoveDialog as EventListener); + return () => { + window.removeEventListener("open-move-session-dialog", handleOpenMoveDialog as EventListener); + }; + }, [handleOpenMoveDialog]); + + const handleMoveConfirm = async (targetProjectId: string | null) => { + if (!sessionToMove) return; + + await api.webui.patch(`/api/v1/sessions/${sessionToMove.id}/project`, { projectId: targetProjectId }); + + // Dispatch event to notify other components + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("session-moved", { + detail: { + sessionId: sessionToMove.id, + projectId: targetProjectId, + }, + }) + ); + // Also trigger session-updated to refresh the list + window.dispatchEvent(new CustomEvent("session-updated", { detail: { sessionId: sessionToMove.id } })); + } + + addNotification?.("Session moved successfully", "success"); + }; + + const handleItemClick = (itemId: string, item: NavItem) => { + setActiveItem(itemId); + + if (item.onClick) { + item.onClick(); + return; + } + + // Handle navigation based on item id using React Router + switch (itemId) { + case "agents": + onNavigate?.("agentMesh"); + navigate("/agents"); + break; + case "chats": + onNavigate?.("chat"); + navigate("/chat"); + break; + case "projects": + onNavigate?.("projects"); + navigate("/projects"); + break; + case "prompts": + onNavigate?.("prompts"); + navigate("/prompts"); + break; + case "artifacts": + onNavigate?.("artifacts"); + navigate("/artifacts"); + break; + } + }; + + const handleNewChatClick = () => { + // Switch to chat view first, then directly start new session + navigate("/chat"); + handleNewSession(); + }; + + const toggleMenu = (menuId: string) => { + setExpandedMenus(prev => ({ + ...prev, + [menuId]: !prev[menuId], + })); + }; + + // Define navigation items matching the screenshot + const navItems: NavItem[] = useMemo(() => { + const items: NavItem[] = []; + + // Projects + if (projectsEnabled) { + items.push({ + id: "projects", + label: "Projects", + icon: FolderOpen, + }); + } + + // Assets with submenu + items.push({ + id: "assets", + label: "Assets", + icon: BookOpenText, + hasSubmenu: true, + children: [ + { id: "artifacts", label: "Artifacts", icon: BookOpenText }, + { id: "prompts", label: "Prompts", icon: BookOpenText, tooltip: "Experimental Feature" }, + ], + }); + + // Agents + items.push({ + id: "agents", + label: "Agents", + icon: Bot, + }); + + // System Management with submenu + items.push({ + id: "systemManagement", + label: "System Management", + icon: LayoutGrid, + hasSubmenu: true, + children: [ + { id: "agentManagement", label: "Agent Management", icon: Settings }, + { id: "activities", label: "Activities", icon: Settings }, + ], + }); + + return items; + }, [projectsEnabled]); + return ( -
-
-
- -
Chats
-
- -
- -
-
- - {/* Chat Sessions */} -
- -
+
+ {isCollapsed ? ( + /* Collapsed View - Icon Only */ + <> + {/* Header with Short Logo */} +
+ + {/* Expand Chevron - positioned outside the panel */} + +
+ + {/* Icon Stack */} +
+ {/* New Chat */} + + + {/* Navigation Icons */} + {projectsEnabled && ( + + )} + + + +
+ + {/* Bottom items */} +
+ + + {logoutEnabled && ( + + )} +
+ + ) : ( + /* Expanded View */ + <> + {/* Header with Solace Logo and Collapse Button */} +
+
+ +
+ +
+ + {/* Scrollable Navigation Section */} +
+ {/* New Chat Button */} + + + {/* Navigation Items */} +
+ {navItems.map(item => { + // Check if any child is active + const hasActiveChild = item.children?.some(child => activeItem === child.id) ?? false; + return ( +
+ handleItemClick(item.id, item)} + isExpanded={expandedMenus[item.id]} + onToggleExpand={() => toggleMenu(item.id)} + hasActiveChild={hasActiveChild} + /> + {/* Submenu items with vertical line */} + {item.hasSubmenu && expandedMenus[item.id] && item.children && ( +
+ {/* Vertical section line */} +
+ {item.children.map(child => { + const isChildActive = activeItem === child.id; + return ( +
+ {/* Selected state line - thicker when active */} + {isChildActive &&
} + handleItemClick(child.id, child)} indent /> +
+ ); + })} +
+ )} +
+ ); + })} +
+ + {/* Divider */} +
+ + {/* Recent Chats Section */} +
+ Recent Chats + +
+ + {/* Recent Chats List - fills available space until Notifications */} +
+ +
+
+ + {/* Bottom Section - Notifications and User Account */} + {/* Spacing: 8px above divider (mt-2) + 8px below divider (pt-2) = 16px total */} +
+ + + {logoutEnabled && ( + + )} +
+ + )} + + {/* Move Session Dialog */} + { + setIsMoveDialogOpen(false); + setSessionToMove(null); + }} + onConfirm={handleMoveConfirm} + session={sessionToMove} + projects={projects} + currentProjectId={sessionToMove?.projectId} + /> + + {/* Settings Dialog */} +
); }; diff --git a/client/webui/frontend/src/lib/components/chat/index.ts b/client/webui/frontend/src/lib/components/chat/index.ts index 6d0ccfe6d9..36f232c1f1 100644 --- a/client/webui/frontend/src/lib/components/chat/index.ts +++ b/client/webui/frontend/src/lib/components/chat/index.ts @@ -4,8 +4,12 @@ export { ChatMessage } from "./ChatMessage"; export { ChatSessionDeleteDialog } from "./ChatSessionDeleteDialog"; export { ChatSessionDialog } from "./ChatSessionDialog"; export { ChatSessions } from "./ChatSessions"; +export { LegacyChatSessions } from "./LegacyChatSessions"; +export { LegacySessionList } from "./LegacySessionList"; export { ChatSidePanel } from "./ChatSidePanel"; export { LoadingMessageRow } from "./LoadingMessageRow"; +export { LegacySessionSidePanel } from "./LegacySessionSidePanel"; +export { RecentChatsList } from "./RecentChatsList"; export { SessionSidePanel } from "./SessionSidePanel"; export { MoveSessionDialog } from "./MoveSessionDialog"; export { VariableDialog } from "./VariableDialog"; diff --git a/client/webui/frontend/src/lib/components/common/SolaceIcon.tsx b/client/webui/frontend/src/lib/components/common/SolaceIcon.tsx new file mode 100644 index 0000000000..e711a825de --- /dev/null +++ b/client/webui/frontend/src/lib/components/common/SolaceIcon.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import solaceLogoFull from "@/assets/solace-logo-full.svg"; +import solaceLogoShort from "@/assets/solace-logo-s.svg"; + +interface SolaceIconProps { + className?: string; + variant?: "full" | "short"; +} + +export const SolaceIcon: React.FC = ({ className, variant = "full" }) => { + const logoSrc = variant === "short" ? solaceLogoShort : solaceLogoFull; + + return Solace; +}; diff --git a/client/webui/frontend/src/lib/components/common/index.ts b/client/webui/frontend/src/lib/components/common/index.ts index a82e72d902..2b39ec7148 100644 --- a/client/webui/frontend/src/lib/components/common/index.ts +++ b/client/webui/frontend/src/lib/components/common/index.ts @@ -14,3 +14,4 @@ export { MessageBanner } from "./MessageBanner"; export * from "./messageColourVariants"; export * from "./StreamingMarkdown"; export { UserTypeahead } from "./UserTypeahead"; +export { SolaceIcon } from "./SolaceIcon"; diff --git a/client/webui/frontend/src/lib/components/index.ts b/client/webui/frontend/src/lib/components/index.ts index 26d8567ef5..abb6e60729 100644 --- a/client/webui/frontend/src/lib/components/index.ts +++ b/client/webui/frontend/src/lib/components/index.ts @@ -13,6 +13,9 @@ export * from "./header"; export * from "./pages"; export * from "./agents"; export * from "./workflows"; + +// Re-export NavigationSidebarWrapper as NavigationSidebar for backwards compatibility +export { NavigationSidebarWrapper as NavigationSidebar } from "./navigation"; // Export workflow visualization components (selective to avoid conflicts with activities) export { WorkflowVisualizationPage, diff --git a/client/webui/frontend/src/lib/components/navigation/LegacyNavigationButton.tsx b/client/webui/frontend/src/lib/components/navigation/LegacyNavigationButton.tsx new file mode 100644 index 0000000000..95ee5b1f30 --- /dev/null +++ b/client/webui/frontend/src/lib/components/navigation/LegacyNavigationButton.tsx @@ -0,0 +1,55 @@ +import React from "react"; + +import { cn } from "@/lib/utils"; +import type { NavigationItem } from "@/lib/types"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/lib/components/ui/tooltip"; +import { ExperimentalBadge } from "@/lib/components/ui/experimental-badge"; + +interface NavigationItemProps { + item: NavigationItem; + isActive: boolean; + onItemClick?: (itemId: string) => void; +} + +export const LegacyNavigationButton: React.FC = ({ item, isActive, onItemClick }) => { + const { id, label, icon: Icon, disabled, badge } = item; + + const handleClick = () => { + if (!disabled && onItemClick) { + onItemClick(id); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + handleClick(); + } + }; + + return ( + + + + + {label} + + ); +}; diff --git a/client/webui/frontend/src/lib/components/navigation/LegacyNavigationList.tsx b/client/webui/frontend/src/lib/components/navigation/LegacyNavigationList.tsx new file mode 100644 index 0000000000..4a4c8af55e --- /dev/null +++ b/client/webui/frontend/src/lib/components/navigation/LegacyNavigationList.tsx @@ -0,0 +1,116 @@ +import React, { useState } from "react"; +import { Settings, LogOut, User } from "lucide-react"; + +import { LegacyNavigationButton } from "@/lib/components/navigation/LegacyNavigationButton"; +import type { NavigationItem } from "@/lib/types"; +import { Popover, PopoverTrigger, PopoverContent, Tooltip, TooltipTrigger, TooltipContent, Menu } from "@/lib/components/ui"; +import { SettingsDialog } from "@/lib/components/settings"; +import { useAuthContext, useConfigContext } from "@/lib/hooks"; + +interface NavigationListProps { + items: NavigationItem[]; + bottomItems?: NavigationItem[]; + activeItem: string | null; + onItemClick: (itemId: string) => void; +} + +export const LegacyNavigationList: React.FC = ({ items, bottomItems, activeItem, onItemClick }) => { + const [menuOpen, setMenuOpen] = useState(false); + const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); + + // When authorization is enabled, show menu with user info and settings/logout + const { configUseAuthorization, configFeatureEnablement } = useConfigContext(); + const logoutEnabled = configUseAuthorization && configFeatureEnablement?.logout ? true : false; + + const { userInfo, logout } = useAuthContext(); + const userName = typeof userInfo?.username === "string" ? userInfo.username : "Guest"; + + const handleSettingsClick = () => { + setMenuOpen(false); + setSettingsDialogOpen(true); + }; + const handleLogoutClick = async () => { + setMenuOpen(false); + await logout(); + }; + + return ( + + ); +}; diff --git a/client/webui/frontend/src/lib/components/navigation/LegacyNavigationSidebar.tsx b/client/webui/frontend/src/lib/components/navigation/LegacyNavigationSidebar.tsx new file mode 100644 index 0000000000..a9c470e32e --- /dev/null +++ b/client/webui/frontend/src/lib/components/navigation/LegacyNavigationSidebar.tsx @@ -0,0 +1,33 @@ +import React from "react"; + +import { NavigationHeader } from "@/lib/components/navigation"; +import { LegacyNavigationList } from "@/lib/components/navigation/LegacyNavigationList"; +import type { NavigationItem } from "@/lib/types"; + +interface LegacyNavigationSidebarProps { + items: NavigationItem[]; + bottomItems?: NavigationItem[]; + activeItem: string; + onItemChange: (itemId: string) => void; + onHeaderClick?: () => void; +} + +/** + * Legacy Navigation Sidebar - Original simple navigation from main branch + * This is the old navigation that was used before the new collapsible sidebar + */ +export const LegacyNavigationSidebar: React.FC = ({ items, bottomItems, activeItem, onItemChange, onHeaderClick }) => { + const handleItemClick = (itemId: string) => { + onItemChange(itemId); + }; + + // Filter out theme-toggle from bottomItems + const filteredBottomItems = bottomItems?.filter(item => item.id !== "theme-toggle"); + + return ( + + ); +}; diff --git a/client/webui/frontend/src/lib/components/navigation/NavigationSidebar.tsx b/client/webui/frontend/src/lib/components/navigation/NavigationSidebar.tsx index 4013abd35e..c0d54d8c6f 100644 --- a/client/webui/frontend/src/lib/components/navigation/NavigationSidebar.tsx +++ b/client/webui/frontend/src/lib/components/navigation/NavigationSidebar.tsx @@ -1,28 +1,528 @@ -import React from "react"; +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; -import { NavigationHeader, NavigationList } from "@/lib/components/navigation"; -import type { NavigationItem } from "@/lib/types"; +import { Plus, Bot, FolderOpen, BookOpenText, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Bell, User, LayoutGrid, Settings, LogOut } from "lucide-react"; + +import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@/lib/components/ui"; +import { useChatContext, useConfigContext, useAuthContext } from "@/lib/hooks"; +import { useProjectContext } from "@/lib/providers"; +import { SolaceIcon } from "@/lib/components/common/SolaceIcon"; +import { MoveSessionDialog } from "@/lib/components/chat/MoveSessionDialog"; +import { SettingsDialog } from "@/lib/components/settings/SettingsDialog"; +import { RecentChatsList } from "@/lib/components/chat/RecentChatsList"; +import { MAX_RECENT_CHATS } from "@/lib/constants/ui"; +import { api } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import type { Session } from "@/lib/types"; + +interface NavItem { + id: string; + label: string; + icon: React.ElementType; + onClick?: () => void; + badge?: string; + tooltip?: string; + hasSubmenu?: boolean; + children?: NavItem[]; +} + +// Navigation item button component - uses dark theme colors always +const NavItemButton: React.FC<{ + item: NavItem; + isActive: boolean; + onClick: () => void; + isExpanded?: boolean; + onToggleExpand?: () => void; + className?: string; + indent?: boolean; + hasActiveChild?: boolean; +}> = ({ item, isActive, onClick, isExpanded, onToggleExpand, className, indent, hasActiveChild }) => { + const buttonContent = ( + + ); + + if (item.tooltip) { + return ( + + {buttonContent} + +

{item.tooltip}

+
+
+ ); + } + + return buttonContent; +}; interface NavigationSidebarProps { - items: NavigationItem[]; - bottomItems?: NavigationItem[]; - activeItem: string; - onItemChange: (itemId: string) => void; - onHeaderClick?: () => void; + onToggle?: () => void; + isCollapsed?: boolean; + onNavigate?: (page: string) => void; + /** + * Items for the System Management submenu (enterprise-only feature). + */ + additionalSystemManagementItems?: Array<{ id: string; label: string; icon?: React.ElementType }>; + /** Additional top-level navigation items (for enterprise extensions like Gateways) */ + additionalNavItems?: Array<{ id: string; label: string; icon: React.ElementType; position?: "before-agents" | "after-agents" | "after-system-management" }>; } -export const NavigationSidebar: React.FC = ({ items, bottomItems, activeItem, onItemChange, onHeaderClick }) => { - const handleItemClick = (itemId: string) => { - onItemChange(itemId); +export const NavigationSidebar: React.FC = ({ onToggle, isCollapsed = false, onNavigate, additionalSystemManagementItems, additionalNavItems = [] }) => { + const { logout } = useAuthContext(); + const navigate = useNavigate(); + const location = useLocation(); + const [activeItem, setActiveItem] = useState("chats"); + const [expandedMenus, setExpandedMenus] = useState>({ + assets: false, + systemManagement: false, + }); + const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false); + const [sessionToMove, setSessionToMove] = useState(null); + const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false); + const { handleNewSession, addNotification } = useChatContext(); + const { configUseAuthorization, configFeatureEnablement } = useConfigContext(); + const { projects } = useProjectContext(); + + // Feature flags + const projectsEnabled = configFeatureEnablement?.projects ?? false; + const logoutEnabled = configUseAuthorization && configFeatureEnablement?.logout ? true : false; + + // Sync active item with current route + useEffect(() => { + const path = location.pathname; + if (path.startsWith("/agents")) { + setActiveItem("agents"); + } else if (path.startsWith("/projects")) { + setActiveItem("projects"); + } else if (path.startsWith("/prompts")) { + setActiveItem("prompts"); + // Auto-expand Assets menu when on prompts page + setExpandedMenus(prev => ({ ...prev, assets: true })); + } else if (path.startsWith("/artifacts")) { + setActiveItem("artifacts"); + // Auto-expand Assets menu when on artifacts page + setExpandedMenus(prev => ({ ...prev, assets: true })); + } else { + setActiveItem("chats"); + } + }, [location.pathname]); + + // Handle move session dialog event + const handleOpenMoveDialog = useCallback((event: CustomEvent<{ session: Session }>) => { + setSessionToMove(event.detail.session); + setIsMoveDialogOpen(true); + }, []); + + useEffect(() => { + window.addEventListener("open-move-session-dialog", handleOpenMoveDialog as EventListener); + return () => { + window.removeEventListener("open-move-session-dialog", handleOpenMoveDialog as EventListener); + }; + }, [handleOpenMoveDialog]); + + const handleMoveConfirm = async (targetProjectId: string | null) => { + if (!sessionToMove) return; + + await api.webui.patch(`/api/v1/sessions/${sessionToMove.id}/project`, { projectId: targetProjectId }); + + // Dispatch event to notify other components + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("session-moved", { + detail: { + sessionId: sessionToMove.id, + projectId: targetProjectId, + }, + }) + ); + // Also trigger session-updated to refresh the list + window.dispatchEvent(new CustomEvent("session-updated", { detail: { sessionId: sessionToMove.id } })); + } + + addNotification?.("Session moved successfully", "success"); + }; + + const handleItemClick = (itemId: string, item: NavItem) => { + setActiveItem(itemId); + + if (item.onClick) { + item.onClick(); + return; + } + + // Handle navigation based on item id using React Router + // First call onNavigate callback so enterprise can handle custom navigation + onNavigate?.(itemId); + + // Then handle known routes + switch (itemId) { + case "agents": + navigate("/agents"); + break; + case "chats": + navigate("/chat"); + break; + case "projects": + navigate("/projects"); + break; + case "prompts": + navigate("/prompts"); + break; + case "artifacts": + navigate("/artifacts"); + break; + // For unknown items (like enterprise-specific ones), + // the onNavigate callback above handles navigation + default: + // Try to navigate to /{itemId} as a fallback + navigate(`/${itemId}`); + break; + } + }; + + const handleNewChatClick = () => { + // Switch to chat view first, then directly start new session + navigate("/chat"); + handleNewSession(); + }; + + const toggleMenu = (menuId: string) => { + setExpandedMenus(prev => ({ + ...prev, + [menuId]: !prev[menuId], + })); + }; + + const handleToggle = () => { + onToggle?.(); }; - // Filter out theme-toggle from bottomItems - const filteredBottomItems = bottomItems?.filter(item => item.id !== "theme-toggle"); + // Define navigation items matching the screenshot + const navItems: NavItem[] = useMemo(() => { + const items: NavItem[] = []; + + // Projects + if (projectsEnabled) { + items.push({ + id: "projects", + label: "Projects", + icon: FolderOpen, + }); + } + + // Assets with submenu + items.push({ + id: "assets", + label: "Assets", + icon: BookOpenText, + hasSubmenu: true, + children: [ + { id: "artifacts", label: "Artifacts", icon: BookOpenText }, + { id: "prompts", label: "Prompts", icon: BookOpenText, tooltip: "Experimental Feature" }, + ], + }); + + // Add additional nav items positioned "before-agents" + additionalNavItems + .filter(item => item.position === "before-agents") + .forEach(item => { + items.push({ + id: item.id, + label: item.label, + icon: item.icon, + }); + }); + + // Agents + items.push({ + id: "agents", + label: "Agents", + icon: Bot, + }); + + // Add additional nav items positioned "after-agents" + additionalNavItems + .filter(item => item.position === "after-agents") + .forEach(item => { + items.push({ + id: item.id, + label: item.label, + icon: item.icon, + }); + }); + + // System Management with submenu (enterprise-only) + // Only shown when additionalSystemManagementItems is provided + if (additionalSystemManagementItems && additionalSystemManagementItems.length > 0) { + const systemManagementChildren: NavItem[] = [{ id: "agentManagement", label: "Agent Management", icon: Settings }]; + + // Add any additional items passed from enterprise (e.g., Activities) + additionalSystemManagementItems.forEach(item => { + systemManagementChildren.push({ + id: item.id, + label: item.label, + icon: item.icon || Settings, + }); + }); + + items.push({ + id: "systemManagement", + label: "System Management", + icon: LayoutGrid, + hasSubmenu: true, + children: systemManagementChildren, + }); + } + + // Add additional nav items positioned "after-system-management" or with no position specified + additionalNavItems + .filter(item => item.position === "after-system-management" || !item.position) + .forEach(item => { + items.push({ + id: item.id, + label: item.label, + icon: item.icon, + }); + }); + + return items; + }, [projectsEnabled, additionalSystemManagementItems, additionalNavItems]); return ( -