From f2b326b63bcad62114237954d47c737dbf1889f6 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:17:51 +0530 Subject: [PATCH] feat(playground): implement Playground layout and navigation structure - Introduced PlaygroundLayoutShell component for managing the layout and navigation of the API Playground. - Added PlaygroundLayout component to handle workspace-specific rendering. - Removed PlaygroundSidebar and related state management to simplify the UI and enhance mobile responsiveness. - Updated LayoutDataProvider and LayoutShell components to reflect the new Playground structure. --- .../playground/layout-shell.tsx | 192 ++++++++++++++++++ .../[workspace_id]/playground/layout.tsx | 15 ++ surfsense_web/atoms/layout/playground.atom.ts | 10 - .../layout/providers/LayoutDataProvider.tsx | 39 +--- .../layout/ui/shell/LayoutShell.tsx | 23 +-- .../layout/ui/sidebar/PlaygroundSidebar.tsx | 94 --------- .../components/layout/ui/sidebar/index.ts | 1 - 7 files changed, 214 insertions(+), 160 deletions(-) create mode 100644 surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx create mode 100644 surfsense_web/app/dashboard/[workspace_id]/playground/layout.tsx delete mode 100644 surfsense_web/atoms/layout/playground.atom.ts delete mode 100644 surfsense_web/components/layout/ui/sidebar/PlaygroundSidebar.tsx diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx new file mode 100644 index 000000000..0d0211f99 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/layout-shell.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { History, KeyRound, LayoutGrid } from "lucide-react"; +import Link from "next/link"; +import { useSelectedLayoutSegments } from "next/navigation"; +import type React from "react"; +import { useCallback, useMemo, useState } from "react"; +import { Separator } from "@/components/ui/separator"; +import { PLAYGROUND_PLATFORMS, type PlatformIcon } from "@/lib/playground/catalog"; +import { cn } from "@/lib/utils"; + +interface PlaygroundLayoutShellProps { + workspaceId: string; + children: React.ReactNode; +} + +type PlaygroundNavItem = + | { + type: "item"; + value: string; + label: string; + href: string; + icon: React.ReactNode; + indented?: boolean; + } + | { + type: "section"; + value: string; + label: string; + icon: PlatformIcon; + }; + +function PlaygroundNavLink({ + item, + activeValue, +}: { + item: Extract; + activeValue: string; +}) { + const isActive = activeValue === item.value; + + return ( + + {item.icon} + {item.label} + + ); +} + +export function PlaygroundLayoutShell({ workspaceId, children }: PlaygroundLayoutShellProps) { + const segments = useSelectedLayoutSegments(); + const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start"); + const base = `/dashboard/${workspaceId}/playground`; + + const handleTabScroll = useCallback((e: React.UIEvent) => { + const el = e.currentTarget; + const atStart = el.scrollLeft <= 2; + const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2; + setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle"); + }, []); + + const navItems = useMemo( + () => [ + { + type: "item", + value: "overview", + label: "Overview", + href: base, + icon: , + }, + { + type: "item", + value: "runs", + label: "Runs", + href: `${base}/runs`, + icon: , + }, + { + type: "item", + value: "api-keys", + label: "API Keys", + href: `${base}/api-keys`, + icon: , + }, + ...PLAYGROUND_PLATFORMS.flatMap((platform) => [ + { + type: "section", + value: platform.id, + label: platform.label, + icon: platform.icon, + }, + ...platform.verbs.map((verb) => ({ + type: "item" as const, + value: `${platform.id}/${verb.verb}`, + label: verb.label, + href: `${base}/${platform.id}/${verb.verb}`, + icon: , + indented: true, + })), + ]), + ], + [base] + ); + + const activeValue = + segments.length >= 2 + ? `${segments[0]}/${segments[1]}` + : segments[0] && navItems.some((item) => item.type === "item" && item.value === segments[0]) + ? segments[0] + : "overview"; + const selectedLabel = + navItems.find((item) => item.type === "item" && item.value === activeValue)?.label ?? + "API Playground"; + const mobileItems = navItems.filter( + (item): item is Extract => item.type === "item" + ); + + return ( +
+
+

API Playground

+ +
+
+ {mobileItems.map((item) => ( + + {item.icon} + {item.label} + + ))} +
+
+
+ +
+
+

{selectedLabel}

+ +
+
{children}
+
+
+ ); +} diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/layout.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/layout.tsx new file mode 100644 index 000000000..318886e31 --- /dev/null +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/layout.tsx @@ -0,0 +1,15 @@ +import type React from "react"; +import { use } from "react"; +import { PlaygroundLayoutShell } from "./layout-shell"; + +export default function PlaygroundLayout({ + params, + children, +}: { + params: Promise<{ workspace_id: string }>; + children: React.ReactNode; +}) { + const { workspace_id } = use(params); + + return {children}; +} diff --git a/surfsense_web/atoms/layout/playground.atom.ts b/surfsense_web/atoms/layout/playground.atom.ts deleted file mode 100644 index 6cbe8d276..000000000 --- a/surfsense_web/atoms/layout/playground.atom.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { atom } from "jotai"; - -/** - * Whether the second-level API Playground sidebar is open. Toggled by the - * Playground nav button and kept in memory for the session, so it survives - * in-app navigation (opening a new chat won't close it) and only closes when - * the user clicks the toggle. It defaults to open, so a fresh app load — a new - * signup or a relogin — always starts with the playground visible. - */ -export const playgroundSidebarOpenAtom = atom(true); diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index a70e8f59e..9821a9f83 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { AlarmClock, AlertTriangle, Boxes, SquareTerminal } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -11,7 +11,6 @@ import { toast } from "sonner"; import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom"; import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom"; -import { playgroundSidebarOpenAtom } from "@/atoms/layout/playground.atom"; import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { deleteWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms"; @@ -43,7 +42,6 @@ import { Spinner } from "@/components/ui/spinner"; import { useActivateChatThread } from "@/hooks/use-activate-chat-thread"; import { useAnnouncements } from "@/hooks/use-announcements"; import { useInbox } from "@/hooks/use-inbox"; -import { useIsMobile } from "@/hooks/use-mobile"; import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations"; import { notificationsApiService } from "@/lib/apis/notifications-api.service"; import { workspacesApiService } from "@/lib/apis/workspaces-api.service"; @@ -68,8 +66,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider const params = useParams(); const pathname = usePathname(); const { theme, setTheme } = useTheme(); - const isMobile = useIsMobile(); - const [playgroundSidebarOpen, setPlaygroundSidebarOpen] = useAtom(playgroundSidebarOpenAtom); // Announcements const { unreadCount: announcementUnreadCount } = useAnnouncements(); @@ -316,20 +312,11 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider title: "Playground", url: `/dashboard/${workspaceId}/playground`, icon: SquareTerminal, - // Mobile has no second-level sidebar: Playground is a plain link - // there, so highlight by route. Desktop highlights the toggle state. - isActive: isMobile ? isPlaygroundRoute : playgroundSidebarOpen, + isActive: isPlaygroundRoute, }, ] as (NavItem | null)[] ).filter((item): item is NavItem => item !== null), - [ - workspaceId, - isAutomationsActive, - isArtifactsActive, - isPlaygroundRoute, - playgroundSidebarOpen, - isMobile, - ] + [workspaceId, isAutomationsActive, isArtifactsActive, isPlaygroundRoute] ); // Handlers @@ -466,18 +453,9 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider const handleNavItemClick = useCallback( (item: NavItem) => { - // Desktop: Playground is a persistent toggle, not a plain link — it just - // opens the second-level sidebar (which holds the whole API playground) - // and only closes on a second click, never navigating away from the - // current page (e.g. a new chat). Mobile has no second-level sidebar, - // so there it navigates to the playground index page instead. - if (item.url.endsWith("/playground") && !isMobile) { - setPlaygroundSidebarOpen((prev) => !prev); - return; - } router.push(item.url); }, - [router, setPlaygroundSidebarOpen, isMobile] + [router] ); const handleNewChat = useCallback(() => { @@ -699,7 +677,6 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider setTheme={setTheme} isChatPage={isChatPage} isAllChatsPage={isAllChatsPage} - showPlaygroundSidebar={playgroundSidebarOpen} useWorkspacePanel={useWorkspacePanel} workspacePanelViewportClassName={ isUserSettingsPage || @@ -713,13 +690,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider : undefined } workspacePanelContentClassName={ - isAutomationsPage || isPlaygroundPage - ? "max-w-none select-none" - : isAllChatsPage - ? "max-w-5xl" - : isUserSettingsPage || isWorkspaceSettingsPage || isTeamPage || isArtifactsPage - ? "max-w-5xl" - : undefined + useWorkspacePanel ? "max-w-5xl select-none" : undefined } isLoadingChats={isLoadingThreads} notifications={{ diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 00e606024..06ccaab80 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -27,7 +27,6 @@ import { import { MobileSidebar, MobileSidebarTrigger, - PlaygroundSidebar, Sidebar, SidebarCollapseButton, } from "../sidebar"; @@ -109,7 +108,6 @@ interface LayoutShellProps { defaultCollapsed?: boolean; isChatPage?: boolean; isAllChatsPage?: boolean; - showPlaygroundSidebar?: boolean; useWorkspacePanel?: boolean; workspacePanelViewportClassName?: string; workspacePanelContentClassName?: string; @@ -210,7 +208,6 @@ export function LayoutShell({ defaultCollapsed = false, isChatPage = false, isAllChatsPage = false, - showPlaygroundSidebar = false, useWorkspacePanel = false, workspacePanelViewportClassName, workspacePanelContentClassName, @@ -338,27 +335,11 @@ export function LayoutShell({ /> - {/* Playground second-level sidebar — contextual, desktop only. Sits - between the icon rail and the main sidebar. On Mac it becomes the - leftmost panel, so it takes the rounded-corner/left-border treatment. */} - {showPlaygroundSidebar && activeWorkspaceId != null && ( - - )} - {/* Sidebar + slide-out panels share one container; overflow visible so panels can overlay main content. Negative right margin closes the flex gap so the sidebar sits flush against the main panel, separated only by a border. */}