import { IconChartBar, IconChevronDown, IconTrash, IconDots, IconLoader2, IconStar, IconPencil, IconSettings, IconFilter, IconGripVertical, IconBook2, IconDatabase, IconSearch, IconArchive, IconActivity, IconHeartbeat, IconPlus, IconLock, IconLink, IconMessageCircle, IconUsersGroup, IconEye, IconEyeOff, IconPlayerPlay, IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand, } from "@tabler/icons-react"; import { useQuery, useQueryClient, type QueryClient, type QueryKey, } from "@tanstack/react-query"; import { useTheme } from "next-themes"; import { useState, useEffect, useCallback, useRef, useMemo, Fragment, } from "react"; import { Link, useLocation, useNavigate } from "react-router"; import { toast } from "sonner"; import { getIdToken } from "@/lib/auth"; import { ANALYTICS_CHAT_STORAGE_KEY } from "@/lib/chat-handoff"; import { cn, shortcutModifierLabel } from "@/lib/utils"; import { dashboards, hideDashboard, getHiddenDashboards, getDashboardOrder, setDashboardOrder, type DashboardSubview, } from "@/pages/adhoc/registry"; type SidebarDashboard = { id: string; name: string; subviews?: DashboardSubview[]; source: "static" | "sql" | "analysis"; resourceId?: string; visibility?: Visibility; /** Id of the dashboard this one nests under in the sidebar, if any. */ parentId?: string; }; const SIDEBAR_SYNC_SETTLE_MS = 500; function useSettledSyncVersion(version: number): number { const [settledVersion, setSettledVersion] = useState(version); useEffect(() => { const timeout = window.setTimeout( () => setSettledVersion(version), SIDEBAR_SYNC_SETTLE_MS, ); return () => window.clearTimeout(timeout); }, [version]); return settledVersion; } import { navigateWithAgentChatViewTransition, useChatThreads, type ChatThreadSummary, } from "@agent-native/core/client/agent-chat"; import { appPath } from "@agent-native/core/client/api-path"; import { DevDatabaseLink } from "@agent-native/core/client/db-admin"; import { callAction, useActionMutation, useChangeVersions, } from "@agent-native/core/client/hooks"; import { LanguagePicker, useT } from "@agent-native/core/client/i18n"; import { openCommandMenu } from "@agent-native/core/client/navigation"; import { OrgSwitcher } from "@agent-native/core/client/org"; import { FeedbackButton } from "@agent-native/core/client/ui"; import { SidebarFooterActions } from "@agent-native/toolkit/app-shell"; import { ChatHistoryRail, type ChatHistoryItem, } from "@agent-native/toolkit/chat-history"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Popover, PopoverTrigger, PopoverContent, } from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, } from "@/components/ui/tooltip"; import { useDashboardViews, useDeleteDashboardView, type DashboardView, } from "@/hooks/use-dashboard-views"; import { useUserPref } from "@/hooks/use-user-pref"; import { shouldRenderDashboardList } from "@/lib/dashboard-list-loading"; import { usePopularity, popularityOf } from "@/lib/item-popularity"; import { sqlDashboardPrefetchKey, type PrefetchSnapshot, } from "@/lib/prefetch-keys"; import type { ResourceAccess } from "@/lib/resource-access"; import { NewDashboardDialog } from "./NewDashboardDialog"; import { SidebarLoadError } from "./SidebarLoadError"; const SIDEBAR_PREVIEW_COUNT = 5; const ASK_OPEN_KEY = "analytics-sidebar-ask-open"; const DASHBOARD_SORT_MODE_KEY = "dashboard-sort-mode"; const DASHBOARDS_OPEN_KEY = "analytics-sidebar-dashboards-open"; const SIDEBAR_COLLAPSE_KEY = "analytics.sidebar.collapsed"; const SIDEBAR_SKELETON_CLASS = "bg-sidebar-foreground/12 dark:bg-sidebar-foreground/10"; type SidebarSortMode = "most-used" | "alphabetical" | "manual"; type SidebarVisibilityFilter = "all" | "private" | "shared"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; const bottomItems = [ { icon: IconSettings, labelKey: "navigation.settings", href: "/settings" }, ]; function getStoredBooleanPreference(key: string): boolean | null { if (typeof window === "undefined") return null; try { const raw = window.localStorage.getItem(key); if (raw === "true") return true; if (raw === "false") return false; } catch { // localStorage unavailable — ignore, section state is best-effort. } return null; } function setStoredBoolean(key: string, value: boolean): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(key, String(value)); } catch { // localStorage unavailable — ignore, section state is best-effort. } } function getStoredSortMode(key: string): SidebarSortMode { if (typeof window === "undefined") return "most-used"; const raw = window.localStorage.getItem(key); if (raw === "alphabetical" || raw === "manual" || raw === "most-used") { return raw; } return "most-used"; } function setStoredSortMode(key: string, value: SidebarSortMode): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(key, value); } catch { // localStorage unavailable — ignore, sort mode is best-effort. } } function sortByName(items: T[]): T[] { return [...items].sort((a, b) => { const name = a.name.localeCompare(b.name); return name !== 0 ? name : a.id.localeCompare(b.id); }); } function applyOrder( items: T[], savedOrder: string[], ): T[] { if (savedOrder.length === 0) return items; const idToItem = new Map(items.map((item) => [item.id, item])); const ordered: T[] = []; for (const id of savedOrder) { const item = idToItem.get(id); if (item) { ordered.push(item); idToItem.delete(id); } } // Append any new items not in the saved order for (const item of idToItem.values()) { ordered.push(item); } return ordered; } function matchesVisibilityFilter( item: { visibility?: Visibility }, filter: SidebarVisibilityFilter, ): boolean { if (filter === "all") return true; if (filter === "private") { return item.visibility !== "org" && item.visibility !== "public"; } return item.visibility === "org" || item.visibility === "public"; } function SidebarSectionSettingsPopover({ label, sortMode, onSortModeChange, visibilityFilter, onVisibilityFilterChange, showHidden, onShowHiddenChange, }: { label: string; sortMode: SidebarSortMode; onSortModeChange: (value: SidebarSortMode) => void; visibilityFilter: SidebarVisibilityFilter; onVisibilityFilterChange: (value: SidebarVisibilityFilter) => void; showHidden?: boolean; onShowHiddenChange?: (value: boolean) => void; }) { const t = useT(); const settingsLabel = t("sidebar.sectionSettings", { label }); const segmentedItemClass = "h-7 rounded px-2 text-[11px] text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground data-[state=on]:bg-sidebar-accent data-[state=on]:text-foreground data-[state=on]:shadow-sm"; return ( {settingsLabel}

{label}

{t("sidebar.show")}

{ if (next === "all" || next === "private" || next === "shared") { onVisibilityFilterChange(next); } }} className="grid grid-cols-3 gap-1 rounded-lg border border-border/60 bg-background/50 p-1" > {t("sidebar.visibilityAll")} {t("sidebar.visibilityPrivateOnly")} {t("sidebar.visibilitySharedOnly")} {t("sidebar.visibilitySharedOnlyDescription")}

{t("sidebar.sortBy")}

{ if ( next === "most-used" || next === "alphabetical" || next === "manual" ) { onSortModeChange(next); } }} className="grid grid-cols-3 gap-1 rounded-lg border border-border/60 bg-background/50 p-1" > {t("sidebar.used")} {t("sidebar.usedExplainer")} {t("sidebar.alphabetical")} {t("sidebar.manual")}
{onShowHiddenChange && showHidden !== undefined && ( )}
); } // --- Visibility types and helpers --- type Visibility = "private" | "org" | "public"; // --- Shared sortable row (used by both dashboards and analyses) --- function SortableRow({ id, favoriteKey, name, href, isActive, favoriteIds, onToggleFavorite, onDelete, onRename, onArchive, onHide, onUnhide, hidden, onPrefetch, visibility, onSetVisibility, children, }: { id: string; favoriteKey: string; name: string; href: string; isActive: boolean; favoriteIds: Set; onToggleFavorite: (key: string) => void; onDelete: () => Promise | void; onRename: (name: string) => Promise | void; /** When provided, the menu shows Archive as the primary destructive action * and Delete becomes a confirm-gated "Delete permanently". */ onArchive?: () => Promise | void; /** When provided, the menu shows a Hide item (and Unhide when `hidden`). */ onHide?: () => Promise | void; onUnhide?: () => Promise | void; hidden?: boolean; onPrefetch?: () => void; visibility?: Visibility; onSetVisibility?: (visibility: Visibility) => Promise | void; children?: React.ReactNode; }) { const t = useT(); const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id }); const style = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 50 : undefined, opacity: isDragging ? 0.5 : 1, }; const isFav = favoriteIds.has(favoriteKey); const [menuOpen, setMenuOpen] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [isRenaming, setIsRenaming] = useState(false); const [renameValue, setRenameValue] = useState(name); useEffect(() => { if (!isRenaming) setRenameValue(name); }, [isRenaming, name]); const submitRename = useCallback(async () => { const trimmed = renameValue.trim(); setIsRenaming(false); if (!trimmed || trimmed === name) { setRenameValue(name); return; } try { await onRename(trimmed); } catch (e) { setRenameValue(name); toast.error( e instanceof Error ? t("sidebar.renameFailedWithMessage", { name, message: e.message, }) : t("sidebar.renameFailed", { name }), ); } }, [name, onRename, renameValue, t]); const runDelete = useCallback(async () => { setMenuOpen(false); setConfirmDeleteOpen(false); try { await onDelete(); } catch (e) { toast.error( e instanceof Error ? t("sidebar.deleteFailedWithMessage", { name, message: e.message, }) : t("sidebar.deleteFailed", { name }), ); } }, [name, onDelete, t]); const runArchive = useCallback(async () => { setMenuOpen(false); if (!onArchive) return; try { await onArchive(); } catch (e) { toast.error( e instanceof Error ? t("sidebar.archiveFailedWithMessage", { name, message: e.message, }) : t("sidebar.archiveFailed", { name }), ); } }, [name, onArchive, t]); const runHide = useCallback(async () => { setMenuOpen(false); if (!onHide) return; try { await onHide(); } catch (e) { toast.error( e instanceof Error ? t("sidebar.hideFailedWithMessage", { name, message: e.message, }) : t("sidebar.hideFailed", { name }), ); } }, [name, onHide, t]); const runUnhide = useCallback(async () => { setMenuOpen(false); if (!onUnhide) return; try { await onUnhide(); } catch (e) { toast.error( e instanceof Error ? t("sidebar.unhideFailedWithMessage", { name, message: e.message, }) : t("sidebar.unhideFailed", { name }), ); } }, [name, onUnhide, t]); const runSetVisibility = useCallback( async (visibility: Visibility) => { setMenuOpen(false); if (!onSetVisibility) return; try { await onSetVisibility(visibility); } catch (e) { toast.error( e instanceof Error ? t("sidebar.updateVisibilityFailedWithMessage", { message: e.message, }) : t("sidebar.updateVisibilityFailed"), ); } }, [onSetVisibility, t], ); const copyLink = useCallback(() => { const url = window.location.origin + href; navigator.clipboard.writeText(url).then( () => toast.success(t("sidebar.linkCopied")), () => toast.error(t("sidebar.copyLinkFailed")), ); setMenuOpen(false); }, [href, t]); return (
{isRenaming ? ( setRenameValue(e.target.value)} onBlur={submitRename} onKeyDown={(e) => { if (e.key === "Enter") submitRename(); if (e.key === "Escape") { setRenameValue(name); setIsRenaming(false); } }} className="min-w-0 flex-1 bg-transparent px-2 py-1.5 pe-12 text-xs outline-none" /> ) : ( {name} {name} )}
{isFav ? t("sidebar.unfavoritePersonal") : t("sidebar.favoritePersonal")} {t("sidebar.itemActions", { name })} { setRenameValue(name); setIsRenaming(true); }} > {t("sidebar.rename")} {onSetVisibility && visibility !== undefined && ( { e.preventDefault(); void runSetVisibility( visibility === "private" ? "org" : "private", ); }} > {visibility === "private" ? ( ) : ( )} {visibility === "private" ? t("sidebar.shareWithOrg") : t("sidebar.makePrivate")} )} {t("sidebar.copyLink")} {onUnhide && hidden ? ( { event.preventDefault(); void runUnhide(); }} > {t("sidebar.unhide")} ) : onHide ? ( { event.preventDefault(); void runHide(); }} > {t("sidebar.hide")} ) : null} {onArchive ? ( <> { event.preventDefault(); void runArchive(); }} > {t("sidebar.archive")} { event.preventDefault(); setMenuOpen(false); setConfirmDeleteOpen(true); }} className="text-destructive focus:text-destructive" > {t("sidebar.delete")} ) : ( { event.preventDefault(); setMenuOpen(false); setConfirmDeleteOpen(true); }} className="text-destructive focus:text-destructive" > {t("sidebar.delete")} )}
{children} {t("sidebar.deletePermanentlyTitle")} {t("sidebar.deletePermanentlyDescription", { name })} {onArchive ? t("sidebar.deletePermanentlyArchiveHint") : ""} {t("sidebar.cancel")} void runDelete()} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > {t("sidebar.deletePermanently")}
); } // --- Dashboard item: wraps SortableRow + renders dashboard-specific subviews --- function SortableDashboardItem({ d, isActive, location, favoriteIds, onToggleFavorite, onDelete, onRename, onArchive, onPrefetch, views, onSetVisibility, }: { d: SidebarDashboard; isActive: boolean; location: ReturnType; favoriteIds: Set; onToggleFavorite: (id: string) => void; onDelete: (d: SidebarDashboard) => Promise; onRename: (d: SidebarDashboard, name: string) => Promise; onArchive?: (d: SidebarDashboard) => Promise; onPrefetch?: (d: SidebarDashboard) => void; views?: DashboardView[]; onSetVisibility?: ( d: SidebarDashboard, visibility: Visibility, ) => Promise; }) { const resourceId = d.resourceId ?? d.id; const href = d.source === "analysis" ? `/analyses/${resourceId}` : `/dashboards/${d.id}`; const favoriteKey = d.source === "analysis" ? `analysis:${resourceId}` : d.id; const t = useT(); const { mutateAsync: deleteView } = useDeleteDashboardView(); const [deletingViewId, setDeletingViewId] = useState(null); const allSubviews = useMemo(() => { const items: Array<{ id: string; name: string; href: string; isDynamic: boolean; }> = []; if (d.subviews) { for (const sv of d.subviews) { const svSearch = new URLSearchParams(sv.params).toString(); items.push({ id: sv.id, name: sv.name, href: `${href}?${svSearch}`, isDynamic: false, }); } } if (views) { for (const v of views) { const params = new URLSearchParams(v.filters); params.set("view", v.id); items.push({ id: v.id, name: v.name, href: `${href}?${params.toString()}`, isDynamic: true, }); } } return items; }, [d.subviews, views, href]); return ( onDelete(d)} onRename={(name) => onRename(d, name)} onArchive={ d.source === "analysis" || !onArchive ? undefined : () => onArchive(d) } onPrefetch={() => onPrefetch?.(d)} visibility={d.visibility} onSetVisibility={ onSetVisibility ? (v) => onSetVisibility(d, v) : undefined } > {isActive && allSubviews.length > 0 && (
{allSubviews.map((sv) => { const currentSearch = new URLSearchParams(location.search); const svUrl = new URL(sv.href, window.location.origin); const svParams = new URLSearchParams(svUrl.search); const isSubviewActive = sv.isDynamic ? currentSearch.get("view") === sv.id : Array.from(svParams.entries()).every( ([k, v]) => currentSearch.get(k) === v, ); const isDeleting = sv.isDynamic && deletingViewId === `pending:${sv.id}`; return (
{sv.name} {sv.isDynamic && ( <> {favoriteIds.has(`view:${d.id}:${sv.id}`) ? t("sidebar.unfavoritePersonal") : t("sidebar.favoritePersonal")} setDeletingViewId(open ? sv.id : null) } > {t("sidebar.deleteView", { name: sv.name })}

{t("sidebar.deleteView", { name: sv.name })}

)}
); })}
)}
); } const STATIC_DASHBOARD_RENAMES_KEY = "dashboard-name-overrides"; function getStaticDashboardRenames(): Record { if (typeof window === "undefined") return {}; try { const parsed = JSON.parse( window.localStorage.getItem(STATIC_DASHBOARD_RENAMES_KEY) || "{}", ); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return {}; } return Object.fromEntries( Object.entries(parsed).filter( (entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string" && entry[1].trim().length > 0, ), ); } catch { return {}; } } function setStaticDashboardRenames(renames: Record): void { if (typeof window === "undefined") return; try { window.localStorage.setItem( STATIC_DASHBOARD_RENAMES_KEY, JSON.stringify(renames), ); } catch { // localStorage unavailable / quota — ignore, rename is best-effort } } type SqlDashboardListItem = { id: string; name: string; visibility?: Visibility; parentId?: string; }; async function fetchSqlDashboards( t: (key: string) => string, ): Promise { const rows = await callAction("list-sql-dashboards", {}, { method: "GET" }); return (Array.isArray(rows) ? rows : []) .filter((d: any) => d && typeof d.id === "string" && d.id.length > 0) .map((d: any) => ({ id: d.id, name: typeof d.name === "string" && d.name.trim().length > 0 ? d.name : t("sidebar.untitledDashboard"), visibility: d.visibility === "org" || d.visibility === "public" ? (d.visibility as Visibility) : ("private" as Visibility), parentId: typeof d.parentId === "string" && d.parentId.trim().length > 0 ? d.parentId : undefined, })); } async function fetchSidebarAnalyses(t: (key: string) => string): Promise< { id: string; name: string; visibility: Visibility; hiddenAt: string | null; }[] > { const rows = await callAction("list-analyses", {}, { method: "GET" }); return (Array.isArray(rows) ? rows : []) .filter((a: any) => a && typeof a.id === "string" && a.id.length > 0) .map((a: any) => ({ id: a.id, name: typeof a.name === "string" && a.name.trim().length > 0 ? a.name : t("sidebar.untitledAnalysis"), visibility: a.visibility === "org" || a.visibility === "public" ? a.visibility : ("private" as Visibility), hiddenAt: typeof a.hiddenAt === "string" ? a.hiddenAt : null, })); } type PrefetchedSqlDashboard = { id: string; config: { name: string; description?: string; filters?: unknown; variables?: unknown; columns?: number; panels: unknown[]; }; archivedAt: string | null; hiddenAt: string | null; hiddenBy: string | null; visibility: Visibility; ownerEmail: string | null; updatedAt: string | null; } & ResourceAccess; async function fetchSqlDashboardForPrefetch( id: string, t: (key: string) => string, ): Promise { try { const data: any = await callAction( "get-sql-dashboard", { id, includeConfig: true }, { method: "GET" }, ); if (!data || data.error) return null; return { id, config: { name: typeof data.name === "string" && data.name.trim().length > 0 ? data.name : t("sidebar.untitledDashboard"), description: data.description, filters: data.filters, variables: data.variables, columns: typeof data.columns === "number" ? data.columns : undefined, panels: Array.isArray(data.panels) ? data.panels : [], }, archivedAt: typeof data.archivedAt === "string" ? data.archivedAt : null, hiddenAt: typeof data.hiddenAt === "string" ? data.hiddenAt : null, hiddenBy: typeof data.hiddenBy === "string" ? data.hiddenBy : null, visibility: data.visibility === "org" || data.visibility === "public" ? data.visibility : "private", ownerEmail: typeof data.ownerEmail === "string" ? data.ownerEmail : null, updatedAt: typeof data.updatedAt === "string" ? data.updatedAt : null, role: typeof data.role === "string" ? data.role : undefined, canEdit: typeof data.canEdit === "boolean" ? data.canEdit : undefined, canManage: typeof data.canManage === "boolean" ? data.canManage : undefined, }; } catch { return null; } } const ANALYTICS_ACTIVE_THREAD_KEY = `agent-chat-active-thread:${ANALYTICS_CHAT_STORAGE_KEY}`; function formatThreadAge(updatedAt: number) { const diffMs = Math.max(0, Date.now() - updatedAt); const minutes = Math.floor(diffMs / 60_000); if (minutes < 1) return "now"; if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); if (days < 7) return `${days}d`; return new Date(updatedAt).toLocaleDateString([], { month: "short", day: "numeric", }); } function threadTitle(thread: ChatThreadSummary, untitledLabel: string) { return thread.title || thread.preview || untitledLabel; } function threadUpdatedAt(thread: ChatThreadSummary) { return Number.isFinite(thread.updatedAt) ? thread.updatedAt : Number.isFinite(thread.createdAt) ? thread.createdAt : 0; } function compareThreads(a: ChatThreadSummary, b: ChatThreadSummary) { const aPinned = a.pinnedAt ?? 0; const bPinned = b.pinnedAt ?? 0; if (aPinned || bPinned) return bPinned - aPinned; return threadUpdatedAt(b) - threadUpdatedAt(a); } function persistedAnalyticsThreadId() { if (typeof window === "undefined") return null; try { return window.localStorage.getItem(ANALYTICS_ACTIVE_THREAD_KEY); } catch { return null; } } function AnalyticsChatsSection({ isAskRoute, open, }: { isAskRoute: boolean; open: boolean; }) { const navigate = useNavigate(); const t = useT(); const { threads, activeThreadId, isLoading: chatsLoading, createThread, switchThread, pinThread, archiveThread, renameThread, refreshThreads, } = useChatThreads(undefined, ANALYTICS_CHAT_STORAGE_KEY, undefined, { autoCreate: false, restoreActiveThread: false, }); const visibleThreads = useMemo( () => threads .filter((thread) => thread.messageCount > 0 && !thread.archivedAt) .sort(compareThreads) .slice(0, 15), [threads], ); const chatItems = useMemo( () => visibleThreads.map((thread) => ({ id: thread.id, title: threadTitle(thread, t("chat.untitledChat")), titleText: threadTitle(thread, t("chat.untitledChat")), timestamp: isAskRoute && (thread.id === activeThreadId || thread.id === persistedAnalyticsThreadId()) ? undefined : formatThreadAge(threadUpdatedAt(thread)), pinned: Boolean(thread.pinnedAt), })), [activeThreadId, isAskRoute, t, visibleThreads], ); const displayedActiveThreadId = isAskRoute ? (activeThreadId ?? persistedAnalyticsThreadId()) : null; useEffect(() => { const refresh = () => refreshThreads(); const handleRunning = (event: Event) => { const detail = (event as CustomEvent).detail as | { isRunning?: unknown } | undefined; if (typeof detail?.isRunning === "boolean") refreshThreads(); }; window.addEventListener("agent-chat:threads-updated", refresh); window.addEventListener("agentNative.chatRunning", handleRunning); window.addEventListener("focus", refresh); return () => { window.removeEventListener("agent-chat:threads-updated", refresh); window.removeEventListener("agentNative.chatRunning", handleRunning); window.removeEventListener("focus", refresh); }; }, [refreshThreads]); function openThread(threadId: string, options?: { isNew?: boolean }) { switchThread(threadId); navigateWithAgentChatViewTransition(navigate, "/ask"); window.requestAnimationFrame(() => { window.dispatchEvent( new CustomEvent("agent-chat:open-thread", { detail: { threadId, newThread: options?.isNew === true }, }), ); }); } async function handleNewChat() { const threadId = await createThread(); if (threadId) openThread(threadId, { isNew: true }); } async function handleArchiveThread(threadId: string) { const wasActive = threadId === activeThreadId || threadId === persistedAnalyticsThreadId(); const archived = await archiveThread(threadId); if (!archived) { toast.error(t("chat.archiveFailed")); return; } if (wasActive) { await handleNewChat(); } } function handleRenameThread(threadId: string, title: string) { void renameThread(threadId, title).then((renamed) => { if (!renamed) toast.error(t("chat.renameFailed")); }); } return (
{chatsLoading && visibleThreads.length === 0 && Array.from({ length: 3 }).map((_, i) => (
))} openThread(threadId)} onNewChat={() => void handleNewChat()} railLabels={{ newChat: t("chat.newChat"), showMore: t("sidebar.showMore", { count: Math.max(0, visibleThreads.length - SIDEBAR_PREVIEW_COUNT), }), showLess: t("sidebar.showLess"), }} renameMaxLength={160} onTogglePin={(threadId) => { const thread = visibleThreads.find((item) => item.id === threadId); if (thread) void pinThread(threadId, !thread.pinnedAt); }} onRename={handleRenameThread} onDelete={(threadId) => void handleArchiveThread(threadId)} labels={{ options: (item) => t("chat.optionsFor", { title: item.titleText ?? "" }), renameInput: (item) => t("chat.renameThread", { title: item.titleText ?? "" }), rename: t("chat.renameChat"), pin: t("chat.pinChat"), unpin: t("chat.unpinChat"), delete: t("chat.archiveChat"), }} className="min-w-0" />
); } function getQuerySnapshots(queryClient: QueryClient, queryKey: QueryKey) { return queryClient.getQueriesData({ queryKey }); } function restoreQuerySnapshots( queryClient: QueryClient, snapshots: Array<[QueryKey, T | undefined]>, ) { for (const [key, data] of snapshots) { queryClient.setQueryData(key, data); } } // --- Sidebar --- export function Sidebar({ mobile }: { mobile?: boolean } = {}) { const location = useLocation(); const navigate = useNavigate(); const t = useT(); const queryClient = useQueryClient(); const { setTheme } = useTheme(); const isAskRoute = location.pathname === "/ask"; const activeDashboardId = useMemo(() => { const match = location.pathname.match(/^\/(?:adhoc|dashboards)\/([^/]+)/); if (!match?.[1]) return null; return new URLSearchParams(location.search).get("id") || match[1]; }, [location.pathname, location.search]); const activeAnalysisId = useMemo(() => { const match = location.pathname.match(/^\/analyses\/([^/]+)/); return match?.[1] ?? null; }, [location.pathname]); const [askOpen, setAskOpen] = useState( () => getStoredBooleanPreference(ASK_OPEN_KEY) ?? isAskRoute, ); const [dashOpen, setDashOpen] = useState( () => getStoredBooleanPreference(DASHBOARDS_OPEN_KEY) ?? activeDashboardId !== null, ); const [dashShowAll, setDashShowAll] = useState(false); const [dashFilter, setDashFilter] = useState("all"); const [dashboardSortMode, setDashboardSortModeState] = useState(() => getStoredSortMode(DASHBOARD_SORT_MODE_KEY)); const { data: popularity, isReady: popularityReady } = usePopularity(); useEffect(() => { if (typeof window !== "undefined" && window.localStorage.getItem("theme")) { return; } callAction("get-theme", {}, { method: "GET" }) .then((d) => { if (d?.theme === "light" || d?.theme === "dark") { setTheme(d.theme); } }) .catch(() => {}); }, [setTheme]); const { mutateAsync: renameDashboard } = useActionMutation("rename-dashboard"); const { mutateAsync: renameAnalysis } = useActionMutation("rename-analysis"); const { mutateAsync: setResourceVisibility } = useActionMutation( "set-resource-visibility", ); const { mutateAsync: deleteSqlDashboard } = useActionMutation( "delete-sql-dashboard", { method: "DELETE" }, ); const { mutateAsync: archiveDashboardMut } = useActionMutation("archive-dashboard"); const { mutateAsync: deleteAnalysisMut } = useActionMutation( "delete-analysis", { method: "DELETE" }, ); const [sidebarWidth, setSidebarWidth] = useState(() => { if (typeof window === "undefined") return 256; const saved = localStorage.getItem("sidebar-width"); return saved ? Math.max(180, Math.min(480, Number(saved))) : 256; }); const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { if (typeof window === "undefined") return false; try { return window.localStorage.getItem(SIDEBAR_COLLAPSE_KEY) === "1"; } catch { return false; } }); const effectiveCollapsed = Boolean(sidebarCollapsed && !mobile); const isResizing = useRef(false); const [hiddenIds, setHiddenIds] = useState(() => typeof window === "undefined" ? new Set() : getHiddenDashboards(), ); const [staticDashboardRenames, setStaticDashboardRenamesState] = useState< Record >(() => (typeof window === "undefined" ? {} : getStaticDashboardRenames())); const [dashboardOrderState, setDashboardOrderState] = useState(() => typeof window === "undefined" ? [] : getDashboardOrder(), ); // Server-backed favorites const { data: favoritesData, isLoading: favoritesLoading, isError: favoritesError, isSuccess: favoritesLoaded, save: saveFavorites, } = useUserPref<{ ids: string[]; }>("favorites"); const favoriteIds = useMemo( () => new Set(favoritesData?.ids ?? []), [favoritesData], ); const toggleFavorite = useCallback( (id: string) => { if (favoritesError || !favoritesLoaded) { toast.error(t("sidebar.favoritesUnavailable")); return; } const next = new Set(favoriteIds); if (next.has(id)) { next.delete(id); } else { next.add(id); } saveFavorites({ ids: Array.from(next) }); }, [favoriteIds, favoritesError, favoritesLoaded, saveFavorites], ); const setDashboardSortMode = useCallback((mode: SidebarSortMode) => { setStoredSortMode(DASHBOARD_SORT_MODE_KEY, mode); setDashboardSortModeState(mode); }, []); useEffect(() => { if (getStoredBooleanPreference(ASK_OPEN_KEY) === null) { setAskOpen(isAskRoute); } }, [isAskRoute]); useEffect(() => { if (getStoredBooleanPreference(DASHBOARDS_OPEN_KEY) === null) { setDashOpen(activeDashboardId !== null); } }, [activeDashboardId]); const toggleAskOpen = useCallback(() => { setAskOpen((current) => { const next = !current; setStoredBoolean(ASK_OPEN_KEY, next); return next; }); }, []); const toggleDashOpen = useCallback(() => { setDashOpen((current) => { const next = !current; setStoredBoolean(DASHBOARDS_OPEN_KEY, next); return next; }); }, []); // Fold per-source counters into sidebar list query keys so agent-driven // create/rename/archive/delete shows up without a manual refresh. We // Domain counters keep these lists targeted. Folding the generic `action` // counter into the keys makes unrelated background work cancel and restart // both sidebar reads. const dashboardsSync = useSettledSyncVersion( useChangeVersions(["dashboards"]), ); const analysesSync = useSettledSyncVersion(useChangeVersions(["analyses"])); const dashboardsSyncRef = useRef(dashboardsSync); dashboardsSyncRef.current = dashboardsSync; const { data: sqlDashboards = [], isLoading: sqlDashboardsLoading, isPlaceholderData: sqlDashboardsPlaceholder, isError: sqlDashboardsError, refetch: refetchSqlDashboards, } = useQuery({ queryKey: ["sql-dashboards-sidebar", dashboardsSync], queryFn: () => fetchSqlDashboards(t), staleTime: 30_000, placeholderData: (prev) => prev, }); const { data: analysesList = [], isLoading: analysesLoading, isError: analysesError, refetch: refetchAnalyses, } = useQuery({ queryKey: ["analyses-sidebar", analysesSync], queryFn: () => fetchSidebarAnalyses(t), staleTime: 30_000, placeholderData: (prev) => prev, }); // Only the active dashboard can display saved views in the sidebar, so avoid // issuing one request per dashboard on every sidebar mount. const { views: activeDashboardViews = [] } = useDashboardViews( activeDashboardId ?? undefined, ); const allViewsMap = useMemo>( () => activeDashboardId ? { [activeDashboardId]: activeDashboardViews } : {}, [activeDashboardId, activeDashboardViews], ); const prefetchDashboard = useCallback( (d: SidebarDashboard) => { if (d.source !== "sql") return; const queryKey = sqlDashboardPrefetchKey(d.id); const cached = queryClient.getQueryData< PrefetchSnapshot >(queryKey); void import("@/pages/adhoc/sql-dashboard"); void queryClient.prefetchQuery({ queryKey, queryFn: async () => ({ data: await fetchSqlDashboardForPrefetch(d.id, t), syncVersion: dashboardsSync, }), staleTime: cached?.syncVersion === dashboardsSync ? 30_000 : 0, }); }, [dashboardsSync, queryClient, t], ); const visibleDashboards = useMemo(() => { const staticItems: SidebarDashboard[] = dashboards .filter((d) => !hiddenIds.has(d.id)) .map((d) => ({ id: d.id, name: staticDashboardRenames[d.id] ?? d.name, subviews: d.subviews, source: "static", })); const sqlItems: SidebarDashboard[] = sqlDashboards.map((d) => ({ id: d.id, name: d.name, source: "sql", visibility: d.visibility, parentId: d.parentId, })); const analysisItems: SidebarDashboard[] = analysesList.map((a) => ({ id: `analysis:${a.id}`, resourceId: a.id, name: a.name, source: "analysis", visibility: a.visibility, })); const all = [...staticItems, ...sqlItems, ...analysisItems]; if (dashboardSortMode === "alphabetical") { return sortByName(all); } if (dashboardSortMode === "manual" && dashboardOrderState.length > 0) { return applyOrder(all, dashboardOrderState); } return [...all].sort((a, b) => { const aResourceId = a.resourceId ?? a.id; const bResourceId = b.resourceId ?? b.id; const aFavoriteKey = a.source === "analysis" ? `analysis:${aResourceId}` : a.id; const bFavoriteKey = b.source === "analysis" ? `analysis:${bResourceId}` : b.id; const aFav = favoriteIds.has(aFavoriteKey) ? 0 : 1; const bFav = favoriteIds.has(bFavoriteKey) ? 0 : 1; if (aFav !== bFav) return aFav - bFav; const aPop = popularityOf( popularity, a.source === "analysis" ? "analysis" : "dashboard", aResourceId, ); const bPop = popularityOf( popularity, b.source === "analysis" ? "analysis" : "dashboard", bResourceId, ); if (aPop !== bPop) return bPop - aPop; return a.name.localeCompare(b.name) || a.id.localeCompare(b.id); }); }, [ hiddenIds, staticDashboardRenames, favoriteIds, dashboardSortMode, dashboardOrderState, sqlDashboards, analysesList, popularity, ]); const filteredDashboards = useMemo( () => visibleDashboards.filter((dashboard) => matchesVisibilityFilter(dashboard, dashFilter), ), [visibleDashboards, dashFilter], ); // Group dashboards that declare a parentId beneath their parent. Nesting is // intentionally one level deep: a dashboard only nests when its parent is // itself top-level. Orphans (parent missing/filtered out), self-references, // cycles, and deeper descendants all fall back to top level so nothing is // ever hidden. const dashboardChildren = useMemo>(() => { const byId = new Map(filteredDashboards.map((d) => [d.id, d])); const hasValidParent = (d: SidebarDashboard) => !!d.parentId && d.parentId !== d.id && byId.has(d.parentId); const byParent = new Map(); for (const d of filteredDashboards) { if (!hasValidParent(d)) continue; const parent = byId.get(d.parentId as string); if (!parent || hasValidParent(parent)) continue; const arr = byParent.get(d.parentId as string) ?? []; arr.push(d); byParent.set(d.parentId as string, arr); } return byParent; }, [filteredDashboards]); const topLevelDashboards = useMemo(() => { const childIds = new Set(); for (const arr of dashboardChildren.values()) for (const c of arr) childIds.add(c.id); return filteredDashboards.filter((d) => !childIds.has(d.id)); }, [filteredDashboards, dashboardChildren]); const displayedDashboards = useMemo( () => dashShowAll ? topLevelDashboards : topLevelDashboards.slice(0, SIDEBAR_PREVIEW_COUNT), [topLevelDashboards, dashShowAll], ); const dashboardListHasRendered = useRef(false); const dashboardListReady = shouldRenderDashboardList({ sqlDashboardsLoading, sqlDashboardsPlaceholder, isInitialLoad: !dashboardListHasRendered.current, favoritesLoading, popularityReady, sortMode: dashboardSortMode, }); useEffect(() => { if (dashboardListReady) dashboardListHasRendered.current = true; }, [dashboardListReady]); // The flattened id order exactly as rendered (each parent immediately // followed by its nested children). Drag reordering must use this so the // arrayMove indices match what the user sees; the raw `visibleDashboards` // order interleaves children at their sorted positions and would move the // wrong rows once a dashboard is nested. const dashboardRenderOrderIds = useMemo( () => topLevelDashboards.flatMap((d) => [ d.id, ...(dashboardChildren.get(d.id) ?? []).map((c) => c.id), ]), [topLevelDashboards, dashboardChildren], ); const handleDashboardDelete = useCallback( async (d: SidebarDashboard) => { if (d.source === "analysis") { await deleteAnalysisMut({ id: d.resourceId ?? d.id }); queryClient.invalidateQueries({ queryKey: ["analyses-sidebar"] }); queryClient.invalidateQueries({ queryKey: ["analyses-list"] }); return; } if (d.source === "static") { hideDashboard(d.id); setHiddenIds(getHiddenDashboards()); return; } // Optimistic: remove from the sidebar query cache immediately so the row // disappears without waiting for the DELETE round-trip. Snapshot the // prior value so we can roll back on failure. const activeKey = ["sql-dashboards-sidebar"] as const; const prevActive = getQuerySnapshots( queryClient, activeKey, ); queryClient.setQueriesData( { queryKey: activeKey }, (old) => (old ?? []).filter((item) => item.id !== d.id), ); try { await deleteSqlDashboard({ id: d.id }); queryClient.removeQueries({ queryKey: sqlDashboardPrefetchKey(d.id) }); queryClient.invalidateQueries({ queryKey: activeKey }); } catch (err) { restoreQuerySnapshots(queryClient, prevActive); throw err; } }, [deleteAnalysisMut, deleteSqlDashboard, queryClient], ); const handleDashboardArchive = useCallback( async (d: SidebarDashboard) => { if (d.source === "analysis") return; if (d.source === "static") { // Static dashboards can only be hidden, not archived; route to delete // (which calls hideDashboard for static items). hideDashboard(d.id); setHiddenIds(getHiddenDashboards()); return; } const activeKey = ["sql-dashboards-sidebar"] as const; const prevActive = getQuerySnapshots( queryClient, activeKey, ); queryClient.setQueriesData( { queryKey: activeKey }, (old) => (old ?? []).filter((item) => item.id !== d.id), ); try { await archiveDashboardMut({ id: d.id, archived: true }); queryClient.removeQueries({ queryKey: sqlDashboardPrefetchKey(d.id) }); queryClient.invalidateQueries({ queryKey: activeKey }); toast.success(t("sidebar.archivedName", { name: d.name })); } catch (err) { restoreQuerySnapshots(queryClient, prevActive); throw err; } }, [queryClient, archiveDashboardMut, t], ); const handleDashboardRename = useCallback( async (d: SidebarDashboard, name: string) => { const trimmed = name.trim(); if (!trimmed || trimmed === d.name) return; if (d.source === "analysis") { await renameAnalysis({ id: d.resourceId ?? d.id, name: trimmed }); queryClient.invalidateQueries({ queryKey: ["analyses-sidebar"] }); queryClient.invalidateQueries({ queryKey: ["analyses-list"] }); queryClient.invalidateQueries({ queryKey: ["analysis-detail", d.resourceId ?? d.id], }); return; } if (d.source === "static") { setStaticDashboardRenamesState((prev) => { const next = { ...prev, [d.id]: trimmed }; setStaticDashboardRenames(next); return next; }); return; } const queryKey = ["sql-dashboards-sidebar"] as const; const prev = getQuerySnapshots( queryClient, queryKey, ); queryClient.setQueriesData({ queryKey }, (old) => (old ?? []).map((item) => item.id === d.id ? { ...item, name: trimmed } : item, ), ); try { await renameDashboard({ id: d.id, name: trimmed }); queryClient.removeQueries({ queryKey: sqlDashboardPrefetchKey(d.id) }); queryClient.invalidateQueries({ queryKey }); queryClient.invalidateQueries({ queryKey: ["sql-dashboards-palette"], }); } catch (err) { restoreQuerySnapshots(queryClient, prev); throw err; } }, [queryClient, renameAnalysis, renameDashboard], ); const handleDashboardSetVisibility = useCallback( async (d: SidebarDashboard, visibility: Visibility) => { if (d.source === "static") return; if (d.source === "analysis") { await setResourceVisibility({ resourceType: "analysis", resourceId: d.resourceId ?? d.id, visibility, } as any); queryClient.invalidateQueries({ queryKey: ["analyses-sidebar"] }); queryClient.invalidateQueries({ queryKey: ["analyses-list"] }); toast.success( visibility === "org" ? t("sidebar.nameSharedWithOrg", { name: d.name }) : t("sidebar.nameMadePrivate", { name: d.name }), ); return; } const queryKey = [ "sql-dashboards-sidebar", dashboardsSyncRef.current, ] as const; const prev = getQuerySnapshots( queryClient, queryKey, ); queryClient.setQueriesData({ queryKey }, (old) => (old ?? []).map((item) => item.id === d.id ? { ...item, visibility } : item, ), ); try { await setResourceVisibility({ resourceType: "dashboard", resourceId: d.id, visibility, } as any); queryClient.invalidateQueries({ queryKey }); toast.success( visibility === "org" ? t("sidebar.nameSharedWithOrg", { name: d.name }) : t("sidebar.nameMadePrivate", { name: d.name }), ); } catch (err) { restoreQuerySnapshots(queryClient, prev); throw err; } }, [queryClient, setResourceVisibility, t], ); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const handleDashboardDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const ids = dashboardRenderOrderIds; const oldIndex = ids.indexOf(active.id as string); const newIndex = ids.indexOf(over.id as string); if (oldIndex === -1 || newIndex === -1) return; setDashboardSortMode("manual"); const newOrder = arrayMove(ids, oldIndex, newIndex); setDashboardOrder(newOrder); setDashboardOrderState(newOrder); }, [setDashboardSortMode, dashboardRenderOrderIds], ); const handleResizeStart = useCallback( (e: React.MouseEvent) => { if (effectiveCollapsed) return; e.preventDefault(); isResizing.current = true; const startX = e.clientX; const startWidth = sidebarWidth; const onMouseMove = (ev: MouseEvent) => { if (!isResizing.current) return; const newWidth = Math.max( 180, Math.min(480, startWidth + ev.clientX - startX), ); setSidebarWidth(newWidth); }; const onMouseUp = () => { isResizing.current = false; document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); document.body.style.cursor = ""; document.body.style.userSelect = ""; setSidebarWidth((w) => { localStorage.setItem("sidebar-width", String(w)); return w; }); }; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }, [effectiveCollapsed, sidebarWidth], ); const isAdhocActive = location.pathname.startsWith("/adhoc") || location.pathname.startsWith("/dashboards") || location.pathname.startsWith("/analyses"); useEffect(() => { if (typeof window === "undefined") return; try { window.localStorage.setItem( SIDEBAR_COLLAPSE_KEY, sidebarCollapsed ? "1" : "0", ); } catch { // Ignore storage failures; the in-memory preference still works. } }, [sidebarCollapsed]); const collapsedNavItems = [ { icon: IconMessageCircle, label: t("navigation.ask"), href: "/ask", active: location.pathname === "/ask", onClick: (event: React.MouseEvent) => { if ( location.pathname !== "/ask" && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey ) { event.preventDefault(); navigateWithAgentChatViewTransition(navigate, "/ask"); } }, }, { icon: IconChartBar, label: t("navigation.dashboards"), href: "/dashboards", active: isAdhocActive, }, { icon: IconPlayerPlay, label: t("navigation.sessions"), href: "/sessions", active: location.pathname.startsWith("/sessions"), }, { icon: IconHeartbeat, label: t("navigation.monitoring"), href: "/monitoring", active: location.pathname.startsWith("/monitoring"), }, { icon: IconActivity, label: t("navigation.agents"), href: "/agents", active: location.pathname.startsWith("/agents"), }, { icon: IconDatabase, label: t("navigation.dataSources"), href: "/data-sources", active: location.pathname === "/data-sources", }, { icon: IconBook2, label: t("navigation.dataDictionary"), href: "/data-dictionary", active: location.pathname.startsWith("/data-dictionary"), }, { icon: IconSettings, label: t("navigation.settings"), href: "/settings", active: location.pathname === "/settings", }, ]; const footerSearch = ( {t("sidebar.searchShortcut", { shortcut: `${shortcutModifierLabel()}+K`, })} ); const footerTranslate = ( ); const footerCollapse = !mobile ? ( {effectiveCollapsed ? t("sidebar.expandSidebar") : t("sidebar.collapseSidebar")} ) : null; const footerFeedback = ( ); return (
{!mobile && !effectiveCollapsed && (
)} {effectiveCollapsed ? ( <> ) : ( <>
{t("navigation.brand")}
)}
); }