-
-
{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.icon}
+ {group.label}
+
+
+
+
+
+ {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 (
+
+
+
+ API Playground
+
+
+
+
+ {items.map((item) => (
+
+ ))}
+
+ {groups.map((group) => (
+
+ setExpandedGroup((current) => (current === group.value ? null : group.value))
+ }
+ />
+ ))}
+
+
+
+ );
+}
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 ? (
+