import { AgentSidebar, AgentToggleButton, } 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 { getBrowserTabId } from "@agent-native/core/client/hooks"; import { LanguagePicker, useT } from "@agent-native/core/client/i18n"; import { openCommandMenu } from "@agent-native/core/client/navigation"; import { InvitationBanner, OrgSwitcher, useOrgRole, } from "@agent-native/core/client/org"; import { FeedbackButton } from "@agent-native/core/client/ui"; import { SidebarFooterActions } from "@agent-native/toolkit/app-shell"; import { IconInbox, IconArchive, IconCalendar, IconMicrophone2, IconTrash, IconUsersGroup, IconFolderPlus, IconPlayerRecord, IconAppWindow, IconX, IconMenu2, IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand, IconPlus, IconShare, IconSettings, IconSearch, } from "@tabler/icons-react"; import { ReactNode, useEffect, useMemo, useState } from "react"; import { NavLink, useLocation, useParams } from "react-router"; import { toast } from "sonner"; import { CaptureInstallButton, CaptureInstallInlineLink, } from "@/components/capture-install-options"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { useDesktopPromo } from "@/hooks/use-desktop-promo"; import { useFolders, useSpaces, useOrganizations, useCreateFolder, useRecordingsCount, } from "@/hooks/use-library"; import { useIsMobile } from "@/hooks/use-mobile"; import { usePrefetchVideoStorageStatus } from "@/hooks/use-video-storage-status"; import { cn } from "@/lib/utils"; import { CreateSpaceDialog } from "./create-space-dialog"; import { FolderTree, type FolderNode } from "./folder-tree"; import { PageHeaderSlotProvider } from "./page-header"; import { SearchBar } from "./search-bar"; interface LibraryLayoutProps { children: ReactNode; } const SIDEBAR_COLLAPSED_STORAGE_KEY = "clips:left-sidebar-collapsed"; function readSidebarCollapsedPreference() { if (typeof window === "undefined") return false; try { return ( window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === "true" ); } catch { return false; } } function ClipsAgentToggleButton() { return ; } export function LibraryLayout({ children }: LibraryLayoutProps) { const location = useLocation(); const t = useT(); // Bind chat to the currently-open recording (`/r/:id`). Library, spaces, // meetings, dictate, and settings stay unscoped — those are list-y views // where deck-style "this recording" framing doesn't apply. const recordingScope = useMemo(() => { const match = location.pathname.match(/^\/r\/([^/]+)/); const recordingId = match?.[1]; if (!recordingId) return null; return { type: "recording" as const, id: recordingId }; }, [location.pathname]); const isMobile = useIsMobile(); const { folderId, spaceId } = useParams<{ folderId?: string; spaceId?: string; }>(); const { shouldShowPromo, shouldShowSidebarLink, dismiss } = useDesktopPromo(); usePrefetchVideoStorageStatus(); const { org, canManageOrg } = useOrgRole(); const hasActiveOrg = Boolean(org?.orgId); const { data: organizations } = useOrganizations({ enabled: hasActiveOrg }); const currentOrganizationId = organizations?.currentId ?? organizations?.organizations?.[0]?.id; const { data: spaces } = useSpaces(currentOrganizationId, { enabled: hasActiveOrg && Boolean(currentOrganizationId), }); const { data: libFolders } = useFolders( { organizationId: currentOrganizationId, }, { enabled: hasActiveOrg && Boolean(currentOrganizationId) }, ); // Clip count for the "Library" nav item — count-only, no row payload or // title polling across the app shell. const { data: libraryCount } = useRecordingsCount({ view: "library" }); const { data: sharedCount } = useRecordingsCount({ view: "shared" }); const libFolderList: FolderNode[] = useMemo( () => (libFolders?.folders ?? []) .filter((f: any) => !f.spaceId) .map((f: any) => ({ id: f.id, parentId: f.parentId ?? null, spaceId: f.spaceId ?? null, name: f.name, })), [libFolders], ); const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState( readSidebarCollapsedPreference, ); const [headerSlot, setHeaderSlot] = useState(null); const showCollapsedSidebar = sidebarCollapsed && !isMobile; const collapseButton = !isMobile ? ( {showCollapsedSidebar ? t("navigation.expandSidebar") : t("navigation.collapseSidebar")} ) : null; const searchButton = ( {t("root.commandSearch")} ); const translateButton = ( ); const feedbackButton = ( ); const sidebarHasNewRecordingAction = isMobile ? sidebarOpen : !sidebarCollapsed; // Routes whose page renders its own h-12 toolbar. Layout still mounts Sidebar // + AgentSidebar, but skips its own header so there's no double-header. const pageOwnsToolbar = location.pathname === "/extensions" || location.pathname.startsWith("/extensions/"); const pageHasHeaderSearch = location.pathname.startsWith("/library") || location.pathname === "/shared" || location.pathname === "/archive" || /^\/spaces\/[^/]+/.test(location.pathname); useEffect(() => { setSidebarOpen(false); }, [location.pathname]); useEffect(() => { try { window.localStorage.setItem( SIDEBAR_COLLAPSED_STORAGE_KEY, sidebarCollapsed ? "true" : "false", ); } catch { // Ignore browsers that block localStorage; the toggle still works. } }, [sidebarCollapsed]); const [newFolderOpen, setNewFolderOpen] = useState(false); const [newFolderName, setNewFolderName] = useState(""); const [newSpaceOpen, setNewSpaceOpen] = useState(false); const createFolder = useCreateFolder(); const navItems: { to: string; label: string; icon: React.ComponentType<{ className?: string }>; match: (path: string) => boolean; count?: number; }[] = [ { to: "/library", label: t("navigation.library"), icon: IconInbox, match: (p) => p.startsWith("/library"), count: libraryCount, }, { to: "/shared", label: t("navigation.sharedWithMe"), icon: IconShare, match: (p) => p === "/shared", count: sharedCount, }, { to: "/spaces", label: t("navigation.spaces"), icon: IconUsersGroup, match: (p) => p.startsWith("/spaces"), }, { to: "/meetings", label: t("navigation.meetings"), icon: IconCalendar, match: (p) => p.startsWith("/meetings"), }, { to: "/dictate", label: t("navigation.dictate"), icon: IconMicrophone2, match: (p) => p.startsWith("/dictate"), }, { to: "/archive", label: t("navigation.archive"), icon: IconArchive, match: (p) => p.startsWith("/archive"), }, { to: "/trash", label: t("navigation.trash"), icon: IconTrash, match: (p) => p.startsWith("/trash"), }, ]; const bottomNavItems: { to: string; label: string; icon: React.ComponentType<{ className?: string }>; match: (path: string) => boolean; }[] = [ { to: "/settings", label: t("navigation.settings"), icon: IconSettings, match: (p) => p.startsWith("/settings"), }, ]; return (
{/* Mobile backdrop */} {sidebarOpen && (
setSidebarOpen(false)} /> )} {/* Left sidebar */} {/* Main content area */}
{!pageOwnsToolbar && (
)} {shouldShowPromo && (
{t("navigation.desktopTitle")} {" "} {t("navigation.desktopBody")}
Download {t("clipsFinalRaw.alreadyHaveIt")}
)}
{children}
{/* New folder dialog (library root) */} {t("navigation.newFolder")} setNewFolderName(e.target.value)} placeholder={t("navigation.folderNamePlaceholder")} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30" /> {t("common.cancel")} { const name = newFolderName.trim(); if (!name) return; createFolder.mutate( { name, ...(currentOrganizationId ? { organizationId: currentOrganizationId } : {}), parentId: null, }, { onSuccess: () => toast.success(t("navigation.folderCreated")), onError: (err: any) => toast.error( err?.message ?? t("navigation.createFolderError"), ), }, ); setNewFolderName(""); }} > {t("common.create")}
); }