diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx index beed5d525..05434b953 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/api-reference.tsx @@ -91,12 +91,13 @@ export function ApiReference({

API reference

- Create an API key, enable API access for this workspace, then use the examples below to call this endpoint. + Create an API key, enable API access for this workspace, then use the examples below to + call this endpoint.

- + {snippets.map((snippet) => ( {snippet.label} diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx index f62ffc312..6b426b196 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx @@ -1,13 +1,6 @@ "use client"; -import { - Check, - Copy, - Hash, - Info, - Coins, - Timer, -} from "lucide-react"; +import { Check, Coins, Copy, Hash, Info, Timer } from "lucide-react"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; @@ -99,9 +92,9 @@ function EndpointCopyButton({ endpoint }: { endpoint: string }) { variant="ghost" size="sm" onClick={handleCopy} - className="h-auto justify-start gap-2 rounded bg-muted/40 px-2 py-1 font-mono text-xs text-muted-foreground hover:bg-muted hover:text-foreground" + className="h-auto max-w-full items-start justify-start gap-2 whitespace-normal rounded bg-muted/40 px-2 py-1 font-mono text-xs text-muted-foreground hover:bg-muted hover:text-foreground sm:whitespace-nowrap" > - {endpoint} + {endpoint} {copied ? : } {copied ? "Copied endpoint" : "Copy endpoint"} @@ -243,8 +236,8 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn className="font-medium text-foreground underline-offset-4 hover:underline" > Read docs - - {" "}for more info. + {" "} + for more info. ) : null}

@@ -280,17 +273,12 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn )} -

Output

{isRunning ? ( - + ) : run.status === "cancelled" ? (
Run cancelled. diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx index 59b8ad50b..99bb4dd80 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx @@ -1,15 +1,14 @@ "use client"; -import { History, LayoutGrid } from "lucide-react"; import { useSelectedLayoutSegments } from "next/navigation"; import type React from "react"; import { useMemo } from "react"; import { - type RoutedSectionGroup, - type RoutedSectionItem, + getPlaygroundNavGroups, + getPlaygroundNavItems, + getPlaygroundSelectedLabel, RoutedSectionShell, } from "@/components/layout"; -import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog"; interface PlaygroundLayoutShellProps { workspaceId: string; @@ -20,41 +19,8 @@ export function PlaygroundLayoutShell({ workspaceId, children }: PlaygroundLayou const segments = useSelectedLayoutSegments(); const base = `/dashboard/${workspaceId}/playground`; - const topLevelItems = useMemo( - () => [ - { - value: "overview", - label: "Overview", - href: base, - icon: , - }, - { - value: "runs", - label: "API Runs", - href: `${base}/runs`, - icon: , - }, - ], - [base] - ); - - const providerGroups = useMemo( - () => - PLAYGROUND_PLATFORMS.map((platform) => { - const Icon = platform.icon; - return { - value: platform.id, - label: platform.label, - icon: , - items: platform.verbs.map((verb) => ({ - value: `${platform.id}/${verb.verb}`, - label: verb.label, - href: `${base}/${platform.id}/${verb.verb}`, - })), - }; - }), - [base] - ); + const topLevelItems = useMemo(() => getPlaygroundNavItems(base), [base]); + const providerGroups = useMemo(() => getPlaygroundNavGroups(base), [base]); const activeValue = segments.length >= 2 @@ -63,7 +29,7 @@ export function PlaygroundLayoutShell({ workspaceId, children }: PlaygroundLayou ? segments[0] : "overview"; - const selectedLabel = getSelectedLabel(activeValue, topLevelItems, providerGroups); + const selectedLabel = getPlaygroundSelectedLabel(activeValue, topLevelItems, providerGroups); return ( {children} ); } - -function getSelectedLabel( - activeValue: string, - items: RoutedSectionItem[], - groups: RoutedSectionGroup[] -): string { - const topLevelItem = items.find((item) => item.value === activeValue); - if (topLevelItem) return topLevelItem.label; - - const group = groups.find((item) => item.items.some((child) => child.value === activeValue)); - const child = group?.items.find((item) => item.value === activeValue); - - if (group && child) return `${group.label}: ${child.label}`; - return "API Playground"; -} diff --git a/surfsense_web/components/layout/index.ts b/surfsense_web/components/layout/index.ts index a4d4a88f8..f1f4915d6 100644 --- a/surfsense_web/components/layout/index.ts +++ b/surfsense_web/components/layout/index.ts @@ -14,6 +14,10 @@ export { ChatListItem, CreateWorkspaceDialog, CreditBalanceDisplay, + getPlaygroundActiveValue, + getPlaygroundNavGroups, + getPlaygroundNavItems, + getPlaygroundSelectedLabel, Header, IconRail, LayoutShell, @@ -21,6 +25,7 @@ export { MobileSidebarTrigger, NavIcon, NavSection, + PlaygroundSidebar, RoutedSectionShell, Sidebar, SidebarCollapseButton, diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 9821a9f83..f9c2c9072 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -51,6 +51,7 @@ import { resetUser, trackLogout } from "@/lib/posthog/events"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import type { ChatItem, NavItem, Workspace } from "../types/layout.types"; import { CreateWorkspaceDialog } from "../ui/dialogs"; +import { PlaygroundSidebar } from "../ui/playground/PlaygroundSidebar"; import { LayoutShell } from "../ui/shell"; interface LayoutDataProviderProps { @@ -689,9 +690,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider ? "items-start justify-center px-6 py-8 md:px-10 md:pb-10 md:pt-16" : undefined } - workspacePanelContentClassName={ - useWorkspacePanel ? "max-w-5xl select-none" : undefined - } + workspacePanelContentClassName={useWorkspacePanel ? "max-w-5xl select-none" : undefined} isLoadingChats={isLoadingThreads} notifications={{ totalUnreadCount, @@ -720,6 +719,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider }} onTabSwitch={handleTabSwitch} onTabPrefetch={handleTabPrefetch} + playgroundSidebar={} > {children} diff --git a/surfsense_web/components/layout/ui/RoutedSectionShell.tsx b/surfsense_web/components/layout/ui/RoutedSectionShell.tsx index 007244806..675336b7c 100644 --- a/surfsense_web/components/layout/ui/RoutedSectionShell.tsx +++ b/surfsense_web/components/layout/ui/RoutedSectionShell.tsx @@ -38,6 +38,7 @@ interface RoutedSectionShellProps { groups?: RoutedSectionGroup[]; mobileNav?: "scroll" | "drawer"; contentClassName?: string; + desktopNav?: boolean; } function findActiveGroupValue(groups: RoutedSectionGroup[], activeValue: string): string | null { @@ -142,6 +143,7 @@ export function RoutedSectionShell({ groups = [], mobileNav = "scroll", contentClassName, + desktopNav = true, }: RoutedSectionShellProps) { const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start"); const [drawerOpen, setDrawerOpen] = useState(false); @@ -196,12 +198,17 @@ export function RoutedSectionShell({ ); return ( -
-
+
+

{title}

- + {desktopNav ? : null} {mobileNav === "drawer" ? ( @@ -256,11 +263,15 @@ export function RoutedSectionShell({
-
-

{selectedLabel}

- + {desktopNav ? ( +
+

{selectedLabel}

+ +
+ ) : null} +
+ {children}
-
{children}
); diff --git a/surfsense_web/components/layout/ui/index.ts b/surfsense_web/components/layout/ui/index.ts index 338a4f352..55c39c79e 100644 --- a/surfsense_web/components/layout/ui/index.ts +++ b/surfsense_web/components/layout/ui/index.ts @@ -1,6 +1,13 @@ export { CreateWorkspaceDialog } from "./dialogs"; export { Header } from "./header"; export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail"; +export { + getPlaygroundActiveValue, + getPlaygroundNavGroups, + getPlaygroundNavItems, + getPlaygroundSelectedLabel, + PlaygroundSidebar, +} from "./playground/PlaygroundSidebar"; export type { RoutedSectionGroup, RoutedSectionItem } from "./RoutedSectionShell"; export { RoutedSectionShell } from "./RoutedSectionShell"; export { LayoutShell } from "./shell"; diff --git a/surfsense_web/components/layout/ui/playground/PlaygroundSidebar.tsx b/surfsense_web/components/layout/ui/playground/PlaygroundSidebar.tsx new file mode 100644 index 000000000..60e7b9846 --- /dev/null +++ b/surfsense_web/components/layout/ui/playground/PlaygroundSidebar.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { ChevronRight, History, LayoutGrid } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { Separator } from "@/components/ui/separator"; +import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog"; +import { cn } from "@/lib/utils"; +import type { RoutedSectionGroup, RoutedSectionItem } from "../RoutedSectionShell"; + +interface PlaygroundSidebarProps { + workspaceId: string; + className?: string; +} + +export function getPlaygroundNavItems(base: string): RoutedSectionItem[] { + return [ + { + value: "overview", + label: "Overview", + href: base, + icon: , + }, + { + value: "runs", + label: "API Runs", + href: `${base}/runs`, + icon: , + }, + ]; +} + +export function getPlaygroundNavGroups(base: string): RoutedSectionGroup[] { + return PLAYGROUND_PLATFORMS.map((platform) => { + const Icon = platform.icon; + return { + value: platform.id, + label: platform.label, + icon: , + items: platform.verbs.map((verb) => ({ + value: `${platform.id}/${verb.verb}`, + label: verb.label, + href: `${base}/${platform.id}/${verb.verb}`, + })), + }; + }); +} + +export function getPlaygroundActiveValue( + pathname: string | null, + base: string, + items: RoutedSectionItem[] +): string { + if (!pathname?.startsWith(base)) return ""; + + const rest = pathname.slice(base.length).replace(/^\/+/, ""); + if (!rest) return "overview"; + + const [first, second] = rest.split("/"); + if (second) return `${first}/${second}`; + if (items.some((item) => item.value === first)) return first; + + return "overview"; +} + +export function getPlaygroundSelectedLabel( + activeValue: string, + items: RoutedSectionItem[], + groups: RoutedSectionGroup[] +): string { + const topLevelItem = items.find((item) => item.value === activeValue); + if (topLevelItem) return topLevelItem.label; + + const group = groups.find((item) => item.items.some((child) => child.value === activeValue)); + const child = group?.items.find((item) => item.value === activeValue); + + if (group && child) return `${group.label}: ${child.label}`; + return "API Playground"; +} + +function findActiveGroupValue(groups: RoutedSectionGroup[], activeValue: string): string | null { + return ( + groups.find((group) => group.items.some((item) => item.value === activeValue))?.value ?? null + ); +} + +function PlaygroundNavLink({ + item, + activeValue, +}: { + item: RoutedSectionItem; + activeValue: string; +}) { + const isActive = activeValue === item.value; + + return ( + + {item.icon} + {item.label} + + ); +} + +function PlaygroundNavGroup({ + group, + activeValue, + isExpanded, + onToggle, +}: { + group: RoutedSectionGroup; + activeValue: string; + isExpanded: boolean; + onToggle: () => void; +}) { + return ( +
+ +
+
+
+ {group.items.map((item) => ( + + ))} +
+
+
+
+ ); +} + +export function PlaygroundSidebar({ workspaceId, className }: PlaygroundSidebarProps) { + const pathname = usePathname(); + const base = `/dashboard/${workspaceId}/playground`; + const items = useMemo(() => getPlaygroundNavItems(base), [base]); + const groups = useMemo(() => getPlaygroundNavGroups(base), [base]); + const activeValue = getPlaygroundActiveValue(pathname, base, items); + const [expandedGroup, setExpandedGroup] = useState(() => + findActiveGroupValue(groups, activeValue) + ); + + useEffect(() => { + const activeGroup = findActiveGroupValue(groups, activeValue); + if (activeGroup) { + setExpandedGroup(activeGroup); + } + }, [activeValue, groups]); + + return ( + + ); +} diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 06ccaab80..c9bbe8efc 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -24,12 +24,7 @@ import { RightPanelExpandButton, RightPanelToggleButton, } from "../right-panel/RightPanel"; -import { - MobileSidebar, - MobileSidebarTrigger, - Sidebar, - SidebarCollapseButton, -} from "../sidebar"; +import { MobileSidebar, MobileSidebarTrigger, Sidebar, SidebarCollapseButton } from "../sidebar"; import type { NotificationsDropdownData } from "../sidebar/NotificationsDropdown"; import { TabBar } from "../tabs/TabBar"; import { WorkspacePanel } from "./WorkspacePanel"; @@ -117,6 +112,7 @@ interface LayoutShellProps { isLoadingChats?: boolean; onTabSwitch?: (tab: Tab) => void; onTabPrefetch?: (tab: Tab) => void; + playgroundSidebar?: React.ReactNode; } function MainContentPanel({ @@ -217,11 +213,13 @@ export function LayoutShell({ isLoadingChats = false, onTabSwitch, onTabPrefetch, + playgroundSidebar, }: LayoutShellProps) { const isMobile = useIsMobile(); const electronAPI = useElectronAPI(); const isMacDesktop = electronAPI?.versions.platform === "darwin"; const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [isPlaygroundSidebarCollapsed, setIsPlaygroundSidebarCollapsed] = useState(false); const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed); const { sidebarWidth, @@ -234,6 +232,9 @@ export function LayoutShell({ () => ({ isCollapsed, setIsCollapsed, toggleCollapsed }), [isCollapsed, setIsCollapsed, toggleCollapsed] ); + const handlePlaygroundSidebarToggle = () => { + setIsPlaygroundSidebarCollapsed((collapsed) => !collapsed); + }; // Mobile layout if (isMobile) { @@ -348,6 +349,12 @@ export function LayoutShell({ onToggleCollapse={toggleCollapsed} navItems={navItems} onNavItemClick={onNavItemClick} + onPlaygroundItemClick={ + playgroundSidebar ? handlePlaygroundSidebarToggle : undefined + } + isPlaygroundSidebarOpen={ + playgroundSidebar ? !isPlaygroundSidebarCollapsed : undefined + } chats={chats} activeChatId={activeChatId} onNewChat={onNewChat} @@ -375,10 +382,7 @@ export function LayoutShell({ ) : undefined } - className={cn( - "flex shrink-0", - isMacDesktop && "rounded-tl-xl" - )} + className={cn("flex shrink-0", isMacDesktop && "rounded-tl-xl")} isLoadingChats={isLoadingChats} sidebarWidth={sidebarWidth} isResizing={isResizing} @@ -403,6 +407,21 @@ export function LayoutShell({ )}
+ {playgroundSidebar ? ( +
+
{playgroundSidebar}
+
+ ) : null} + {useWorkspacePanel ? ( - + diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 483d3a43d..7862ae90e 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -52,6 +52,8 @@ interface SidebarProps { onToggleCollapse?: () => void; navItems: NavItem[]; onNavItemClick?: (item: NavItem) => void; + onPlaygroundItemClick?: (item: NavItem) => void; + isPlaygroundSidebarOpen?: boolean; chats: ChatItem[]; activeChatId?: number | null; onNewChat: () => void; @@ -89,6 +91,8 @@ export function Sidebar({ onToggleCollapse, navItems, onNavItemClick, + onPlaygroundItemClick, + isPlaygroundSidebarOpen, chats, activeChatId, onNewChat, @@ -242,9 +246,9 @@ export function Sidebar({ onNavItemClick?.(playgroundItem)} + onClick={() => (onPlaygroundItemClick ?? onNavItemClick)?.(playgroundItem)} isCollapsed={isCollapsed} - isActive={playgroundItem.isActive} + isActive={isPlaygroundSidebarOpen ?? playgroundItem.isActive} badge={New} tooltipContent={isCollapsed ? playgroundItem.title : undefined} />