feat: api playground, other tweaks

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-07-06 16:45:04 -07:00
parent 7bf559cd5b
commit 50f2d095aa
104 changed files with 5482 additions and 244 deletions

View file

@ -116,7 +116,8 @@ import {
promoteRecentMention,
} from "../new-chat/document-mention-picker";
const COMPOSER_PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs";
const COMPOSER_PLACEHOLDER =
"Track competitors, scrape platforms, automate briefs — / for prompts, @ for docs";
type ComposerSuggestionAnchorPoint = {
left: number;

View file

@ -27,7 +27,7 @@ export type HeroChatDemoScript = {
type Stage = "typing" | "steps" | "answer" | "done";
const PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs";
const PLACEHOLDER = "Track competitors, scrape platforms, automate briefs — / for prompts, @ for docs";
/** Blinking caret for the typewriter (overlay only, never inside the real input). */
function Caret() {

View file

@ -66,6 +66,144 @@ const HERO_REALTIME = "/homepage/hero_realtime";
* `src`; everything else plays the chat demo.
*/
const CATEGORIES: HeroCategory[] = [
{
id: "connector-workflows",
label: "Multi-Connector Workflows",
useCases: [
{
id: "launch-impact",
title: "Launch Impact, Across Every Platform",
description:
"One prompt chains Google Search, Reddit, and YouTube into a single cited brief on how a competitor launch actually landed.",
src: null,
demo: {
prompt:
"Our competitor launched v2 yesterday. Measure the reaction across search, Reddit, and YouTube.",
steps: [
{
title: "Google Search",
items: ["Scraping 8 SERPs · launch coverage + AI Overviews"],
},
{
title: "Reddit",
items: ['"competitor v2" · 23 threads in the past 48h'],
},
{
title: "Youtube",
items: ["6 launch videos · 1,904 comments pulled"],
},
{
title: "Plan tasks",
items: ["Merge all three signals into one launch-impact brief"],
},
],
rows: [
{
primary: "5 of 8 SERPs show launch coverage",
secondary: "2 already trigger AI Overviews citing their blog",
},
{
primary: "Reddit: pricing backlash in 9 of 23 threads",
secondary: '"v2 doubled the price" · top thread 412 upvotes',
},
{
primary: "YouTube: creators praise UI, question pricing",
secondary: "61% positive on features · pricing the top complaint",
},
],
summary: "3 connectors, one cited brief · saved to your workspace",
},
},
{
id: "local-teardown",
title: "Local Competitor Teardown",
description:
"Google Maps finds the players, the Web Crawler reads their sites, and Google Search shows who wins the query, in one run.",
src: null,
demo: {
prompt:
'Tear down the top-rated gyms in Austin: reviews, pricing pages, and who ranks for "gym austin".',
steps: [
{
title: "Google Maps",
items: ['"gym austin" · top 10 places + 2,400 reviews'],
},
{
title: "Web Crawler",
items: ["Visiting 10 gym sites", "Extracting pricing and membership pages"],
},
{
title: "Google Search",
items: ['SERP for "gym austin" · organic, ads, map pack'],
},
],
rows: [
{
primary: "Review themes: crowding + billing complaints",
secondary: "appear in 31% of 1-3★ reviews across 10 gyms",
},
{
primary: "Pricing: $89149/mo · 3 hide it behind forms",
secondary: "extracted from all 10 sites with source pages",
},
{
primary: "2 gyms buy ads on their own brand name",
secondary: "map pack and organic top 3 don't overlap",
},
],
summary: "Maps + Crawler + Search in one run · teardown saved",
},
},
{
id: "competitor-360",
title: "Competitor 360, on a Schedule",
description:
"An automation chains four connectors every week: site changes, rank movements, Reddit sentiment, and YouTube reaction.",
src: null,
demo: {
prompt:
"Every Monday, build me a 360 on our top competitor: site changes, rankings, Reddit, and YouTube.",
steps: [
{
title: "Web Crawler",
items: ["pricing + changelog pages · 2 changes detected"],
},
{
title: "Google Search",
items: ["12 shared keywords · rank movements captured"],
},
{
title: "Reddit",
items: ["18 mentions this week · sentiment tagged"],
},
{
title: "Youtube",
items: ["2 new videos · comments and transcripts pulled"],
},
{
title: "Create automation",
items: ["Weekly 360 brief · Mondays 8:00"],
},
],
rows: [
{
primary: "Shipped: usage-based pricing page",
secondary: "pricing + changelog diff · detected Jul 3",
},
{
primary: 'Took #2 on "competitive intelligence api"',
secondary: "you hold #4 · gap widened two weeks in a row",
},
{
primary: "Reddit sentiment down 12 pts since the change",
secondary: "churn signals in 5 threads · quotes linked",
},
],
summary: "4 connectors, 1 automation · first brief lands Monday 8:00",
},
},
],
},
{
id: "competitor-monitoring",
label: "Competitor Monitoring",
@ -580,8 +718,8 @@ export function HeroSection() {
)}
>
SurfSense is an open-source competitive intelligence platform. Your AI agents monitor
competitors, track rankings, and listen to your market with live data from the
platforms that matter, through one API or MCP server.
competitors, track rankings, and listen to your market with live data from platforms
like Reddit, YouTube, Google Maps, Google Search, and the open web.
</p>
<div className="relative mb-4 flex w-full flex-col justify-center gap-y-2 sm:flex-row sm:justify-start sm:space-y-0 sm:space-x-4">

View file

@ -0,0 +1,86 @@
import { ArrowRight, Code2, Megaphone } from "lucide-react";
import Link from "next/link";
import { Reveal } from "@/components/connectors-marketing/reveal";
import { UseCaseArt, type UseCaseArtVariant } from "@/components/homepage/use-case-art";
import { MarketingSection } from "@/components/marketing/section";
/**
* Answers "is this for me?" right below the hero: one card per audience.
* Revenue persona (founders / marketing teams) first, growth persona
* (developers / agent builders) second.
*/
const PATHS: {
icon: typeof Megaphone;
art: UseCaseArtVariant;
eyebrow: string;
title: string;
description: string;
links: { label: string; href: string }[];
}[] = [
{
icon: Megaphone,
art: "chat",
eyebrow: "For founders & marketing teams",
title: "Competitor and market intelligence without the enterprise price tag",
description:
"Ask for a competitor teardown, a lead list, or a weekly market brief in plain English. The agent gathers live data, cites its sources, and automations keep watch so you hear about changes first. Start free, pay only for what you use.",
links: [
{ label: "See what teams build", href: "/connectors" },
{ label: "Pricing", href: "/pricing" },
],
},
{
icon: Code2,
art: "api",
eyebrow: "For developers & agents",
title: "The whole platform is programmable",
description:
"Everything SurfSense agents can do is a typed REST API: scrape Reddit, YouTube, Google Maps, Google Search, and the open web, search the knowledge base, run automations. One key, JSON in and out, $5 free credit, pay as you go. Already running agents in Claude, Cursor, or your own harness? The SurfSense MCP server hands them the same tools natively.",
links: [
{ label: "Read the docs", href: "/docs" },
{ label: "SurfSense MCP server", href: "/mcp-server" },
],
},
];
export function PersonaPaths() {
return (
<MarketingSection>
<Reveal>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">Who SurfSense is for</h2>
</Reveal>
<div className="mt-8 grid gap-6 md:grid-cols-2">
{PATHS.map((path) => {
const Icon = path.icon;
return (
<Reveal key={path.eyebrow}>
<div className="flex h-full flex-col rounded-xl border bg-card p-6">
<UseCaseArt variant={path.art} />
<div className="flex items-center gap-2 text-sm font-medium text-brand">
<Icon className="size-4" aria-hidden />
{path.eyebrow}
</div>
<h3 className="mt-3 text-lg font-semibold">{path.title}</h3>
<p className="mt-2 flex-1 text-sm leading-relaxed text-muted-foreground">
{path.description}
</p>
<div className="mt-4 flex flex-wrap gap-4">
{path.links.map((link) => (
<Link
key={link.href}
href={link.href}
className="group inline-flex items-center gap-1 text-sm font-medium text-foreground"
>
{link.label}
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
</Link>
))}
</div>
</div>
</Reveal>
);
})}
</div>
</MarketingSection>
);
}

View file

@ -5,7 +5,7 @@ import { motion, useReducedMotion } from "motion/react";
const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1];
const VIEWPORT = { once: true, amount: 0.4 } as const;
export type UseCaseArtVariant = "price" | "brand" | "leads" | "serp";
export type UseCaseArtVariant = "price" | "brand" | "leads" | "serp" | "chat" | "api";
/** Soft infinite pulse ring marking the "live" signal in each artifact. */
function Pulse({
@ -282,11 +282,154 @@ function SerpArt({ reduce }: { reduce: boolean }) {
);
}
/** Founders & marketers: a plain-English ask streams back an agent brief with cited sources. */
function ChatArt({ reduce }: { reduce: boolean }) {
const briefLines = [
{ y: 38, width: 150, delay: 0.55 },
{ y: 52, width: 180, delay: 0.7 },
{ y: 66, width: 120, delay: 0.85 },
];
return (
<motion.svg
viewBox="0 0 240 96"
className="h-auto w-full"
initial={reduce ? undefined : "hidden"}
whileInView="visible"
viewport={VIEWPORT}
>
{/* User ask: right-aligned bubble */}
<motion.g
variants={{ hidden: { opacity: 0, y: -6 }, visible: { opacity: 1, y: 0 } }}
transition={{ duration: 0.35, ease: EASE_OUT, delay: 0.1 }}
>
<rect x="112" y="8" width="116" height="18" rx="9" className="fill-muted-foreground/15" />
<rect x="124" y="14" width="92" height="6" rx="3" className="fill-muted-foreground/35" />
</motion.g>
{/* Agent avatar */}
<motion.circle
cx="22"
cy="44"
r="6"
className="fill-brand"
variants={{ hidden: { opacity: 0, scale: 0 }, visible: { opacity: 1, scale: 1 } }}
style={{ transformBox: "fill-box", transformOrigin: "center" }}
transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.4 }}
/>
<Pulse cx={22} cy={44} reduce={reduce} delay={0.7} />
{/* Brief streams in line by line */}
{briefLines.map((line) => (
<motion.rect
key={line.y}
x="36"
y={line.y}
width={line.width}
height="6"
rx="3"
className="fill-muted-foreground/30"
variants={{ hidden: { opacity: 0, x: -8 }, visible: { opacity: 1, x: 0 } }}
transition={{ duration: 0.3, ease: EASE_OUT, delay: line.delay }}
/>
))}
{/* Citation chip */}
<motion.g
variants={{ hidden: { opacity: 0, y: 6 }, visible: { opacity: 1, y: 0 } }}
transition={{ duration: 0.3, ease: EASE_OUT, delay: 1.05 }}
>
<rect x="36" y="76" width="64" height="15" rx="7.5" className="fill-brand/10" />
<text x="68" y="87" textAnchor="middle" className="fill-brand text-[9px] font-medium">
3 sources
</text>
</motion.g>
</motion.svg>
);
}
/** Developers & agents: a typed request gets a 200 and JSON streams back. */
function ApiArt({ reduce }: { reduce: boolean }) {
const jsonLines = [
{ y: 50, text: '{ "items": [', delay: 0.6 },
{ y: 66, text: ' { "title": "...", "score": 812 },', delay: 0.75 },
{ y: 82, text: "] }", delay: 0.9 },
];
return (
<motion.svg
viewBox="0 0 240 96"
className="h-auto w-full"
initial={reduce ? undefined : "hidden"}
whileInView="visible"
viewport={VIEWPORT}
>
{/* Request line */}
<motion.g
variants={{ hidden: { opacity: 0, x: -8 }, visible: { opacity: 1, x: 0 } }}
transition={{ duration: 0.35, ease: EASE_OUT, delay: 0.1 }}
>
<text x="12" y="22" className="fill-brand font-mono text-[10px] font-semibold">
POST
</text>
<text x="44" y="22" className="fill-muted-foreground font-mono text-[10px]">
/scrapers/reddit/scrape
</text>
</motion.g>
{/* 200 OK badge */}
<motion.g
variants={{ hidden: { opacity: 0, scale: 0.8 }, visible: { opacity: 1, scale: 1 } }}
style={{ transformBox: "fill-box", transformOrigin: "center" }}
transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.45 }}
>
<rect x="182" y="10" width="46" height="16" rx="4" className="fill-brand/10" />
<text x="205" y="21" textAnchor="middle" className="fill-brand text-[9px] font-medium">
200 OK
</text>
</motion.g>
<line
x1="12"
y1="32"
x2="228"
y2="32"
className="stroke-muted-foreground/15"
strokeWidth="1"
/>
{/* JSON response streams in */}
{jsonLines.map((line) => (
<motion.text
key={line.y}
x="12"
y={line.y}
className="fill-muted-foreground font-mono text-[10px]"
style={{ whiteSpace: "pre" }}
variants={{ hidden: { opacity: 0, x: -8 }, visible: { opacity: 1, x: 0 } }}
transition={{ duration: 0.3, ease: EASE_OUT, delay: line.delay }}
>
{line.text}
</motion.text>
))}
{/* Blinking cursor after the closing brace */}
<motion.rect
x="34"
y="74"
width="5"
height="10"
className="fill-brand"
variants={{ hidden: { opacity: 0 }, visible: { opacity: 1 } }}
animate={reduce ? undefined : { opacity: [1, 1, 0, 0] }}
transition={
reduce
? { duration: 0.2, delay: 1 }
: { duration: 1, times: [0, 0.5, 0.5, 1], repeat: Infinity, delay: 1 }
}
/>
</motion.svg>
);
}
const ART: Record<UseCaseArtVariant, (props: { reduce: boolean }) => React.ReactNode> = {
price: PriceArt,
brand: BrandArt,
leads: LeadsArt,
serp: SerpArt,
chat: ChatArt,
api: ApiArt,
};
/** Small animated artifact rendered at the top of each use-case card. */

View file

@ -1,8 +1,8 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { AlarmClock, AlertTriangle, Boxes, Inbox } from "lucide-react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { AlarmClock, AlertTriangle, Boxes, Inbox, SquareTerminal } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@ -11,6 +11,7 @@ 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";
@ -42,6 +43,7 @@ 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";
@ -77,6 +79,8 @@ 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();
@ -319,6 +323,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
// list). Documents is embedded below Recents; announcements live in the avatar dropdown.
const isAutomationsActive = pathname?.includes("/automations") === true;
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
const isPlaygroundRoute = pathname?.includes("/playground") === true;
const navItems: NavItem[] = useMemo(
() =>
(
@ -342,6 +347,14 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
icon: Boxes,
isActive: isArtifactsActive,
},
{
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,
},
] as (NavItem | null)[]
).filter((item): item is NavItem => item !== null),
[
@ -350,6 +363,9 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
workspaceId,
isAutomationsActive,
isArtifactsActive,
isPlaygroundRoute,
playgroundSidebarOpen,
isMobile,
]
);
@ -491,9 +507,18 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
setActiveSlideoutPanel((prev) => (prev === "inbox" ? null : "inbox"));
return;
}
// 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]
[router, setPlaygroundSidebarOpen, setActiveSlideoutPanel, isMobile]
);
const handleNewChat = useCallback(() => {
@ -660,6 +685,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
const isTeamPage = pathname?.endsWith("/team") === true;
const isAutomationsPage = pathname?.includes("/automations") === true;
const isArtifactsPage = pathname?.endsWith("/artifacts") === true;
const isPlaygroundPage = pathname?.includes("/playground") === true;
const isAllChatsPage = pathname?.endsWith("/chats") === true;
const handleViewAllChats = useCallback(() => {
setActiveSlideoutPanel(null);
@ -676,6 +702,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
isTeamPage ||
isAutomationsPage ||
isArtifactsPage ||
isPlaygroundPage ||
isAllChatsPage;
return (
@ -714,6 +741,7 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
setTheme={setTheme}
isChatPage={isChatPage}
isAllChatsPage={isAllChatsPage}
showPlaygroundSidebar={playgroundSidebarOpen}
useWorkspacePanel={useWorkspacePanel}
workspacePanelViewportClassName={
isUserSettingsPage ||
@ -721,12 +749,13 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
isTeamPage ||
isAutomationsPage ||
isArtifactsPage ||
isPlaygroundPage ||
isAllChatsPage
? "items-start justify-center px-6 py-8 md:px-10 md:pb-10 md:pt-16"
: undefined
}
workspacePanelContentClassName={
isAutomationsPage
isAutomationsPage || isPlaygroundPage
? "max-w-none select-none"
: isAllChatsPage
? "max-w-5xl"

View file

@ -30,6 +30,7 @@ import {
InboxSidebarContent,
MobileSidebar,
MobileSidebarTrigger,
PlaygroundSidebar,
Sidebar,
SidebarCollapseButton,
} from "../sidebar";
@ -133,6 +134,7 @@ interface LayoutShellProps {
defaultCollapsed?: boolean;
isChatPage?: boolean;
isAllChatsPage?: boolean;
showPlaygroundSidebar?: boolean;
useWorkspacePanel?: boolean;
workspacePanelViewportClassName?: string;
workspacePanelContentClassName?: string;
@ -237,6 +239,7 @@ export function LayoutShell({
defaultCollapsed = false,
isChatPage = false,
isAllChatsPage = false,
showPlaygroundSidebar = false,
useWorkspacePanel = false,
workspacePanelViewportClassName,
workspacePanelContentClassName,
@ -403,11 +406,30 @@ export function LayoutShell({
/>
</div>
{/* 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 && (
<div
className={cn(
"relative hidden md:flex shrink-0 z-20 -mr-2 bg-panel",
isMacDesktop ? "rounded-tl-xl border-t border-r border-l" : "border-r"
)}
>
<PlaygroundSidebar workspaceId={activeWorkspaceId} />
</div>
)}
{/* 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. */}
<div
className={cn(
"relative hidden md:flex shrink-0 z-20 -mr-2 bg-panel",
isMacDesktop ? "rounded-tl-xl border-t border-r border-l" : "border-r"
isMacDesktop
? cn(
"border-t border-r",
!showPlaygroundSidebar && "rounded-tl-xl border-l"
)
: "border-r"
)}
>
<Sidebar
@ -443,7 +465,10 @@ export function LayoutShell({
<Logo disableLink priority className="h-7 w-7 rounded-md" />
) : undefined
}
className={cn("flex shrink-0", isMacDesktop && "rounded-tl-xl")}
className={cn(
"flex shrink-0",
isMacDesktop && !showPlaygroundSidebar && "rounded-tl-xl"
)}
isLoadingChats={isLoadingChats}
sidebarWidth={sidebarWidth}
isResizing={isResizing}

View file

@ -0,0 +1,93 @@
"use client";
import { History, KeyRound } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { PLAYGROUND_PLATFORMS, type PlatformIcon } from "@/lib/playground/catalog";
import { cn } from "@/lib/utils";
interface PlaygroundSidebarProps {
workspaceId: number | string;
}
function PlaygroundNavLink({
href,
label,
icon: Icon,
isActive,
indented = false,
}: {
href: string;
label: string;
icon?: PlatformIcon;
isActive: boolean;
indented?: boolean;
}) {
return (
<Link
href={href}
aria-current={isActive ? "page" : undefined}
className={cn(
"group/link relative flex h-9 items-center gap-2 rounded-md mx-2 px-2 text-sm text-left",
"transition-colors hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
indented && "pl-8",
isActive && "bg-accent text-accent-foreground"
)}
>
{Icon ? <Icon className="h-3.5 w-3.5 shrink-0" /> : null}
<span className="min-w-0 flex-1 truncate">{label}</span>
</Link>
);
}
export function PlaygroundSidebar({ workspaceId }: PlaygroundSidebarProps) {
const pathname = usePathname();
const base = `/dashboard/${workspaceId}/playground`;
return (
<div className="relative flex h-full w-[240px] flex-col bg-panel text-sidebar-foreground overflow-hidden select-none">
<div className="flex h-12 shrink-0 items-center px-4">
<span className="text-sm font-semibold">API Playground</span>
</div>
<div className="flex flex-col gap-0.5 pt-1.5 pb-1.5 after:mx-3 after:mt-1.5 after:block after:h-px after:bg-border">
<PlaygroundNavLink
href={`${base}/runs`}
label="Runs"
icon={History}
isActive={pathname === `${base}/runs`}
/>
<PlaygroundNavLink
href={`${base}/api-keys`}
label="API Keys"
icon={KeyRound}
isActive={pathname === `${base}/api-keys`}
/>
</div>
<div className="flex-1 w-full min-h-0 overflow-y-auto overflow-x-hidden scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent pb-2">
{PLAYGROUND_PLATFORMS.map((platform) => (
<div key={platform.id} className="flex flex-col gap-0.5 pt-2">
<div className="flex items-center gap-2 pl-4 pr-2.5 py-1 text-xs font-medium text-muted-foreground">
<platform.icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{platform.label}</span>
</div>
{platform.verbs.map((verb) => {
const href = `${base}/${platform.id}/${verb.verb}`;
return (
<PlaygroundNavLink
key={verb.name}
href={href}
label={verb.label}
isActive={pathname === href}
indented
/>
);
})}
</div>
))}
</div>
</div>
);
}

View file

@ -150,13 +150,18 @@ export function Sidebar({
() => navItems.find((item) => item.url.endsWith("/artifacts")),
[navItems]
);
const playgroundItem = useMemo(
() => navItems.find((item) => item.url.endsWith("/playground")),
[navItems]
);
const footerNavItems = useMemo(
() =>
navItems.filter(
(item) =>
item.url !== "#inbox" &&
!item.url.endsWith("/automations") &&
!item.url.endsWith("/artifacts")
!item.url.endsWith("/artifacts") &&
!item.url.endsWith("/playground")
),
[navItems]
);
@ -267,6 +272,16 @@ export function Sidebar({
tooltipContent={isCollapsed ? artifactsItem.title : undefined}
/>
)}
{playgroundItem && (
<SidebarButton
icon={playgroundItem.icon}
label={playgroundItem.title}
onClick={() => onNavItemClick?.(playgroundItem)}
isCollapsed={isCollapsed}
isActive={playgroundItem.isActive}
tooltipContent={isCollapsed ? playgroundItem.title : undefined}
/>
)}
</div>
{/* Chat sections - fills available space */}

View file

@ -5,6 +5,7 @@ export { DocumentsSidebar } from "./DocumentsSidebar";
export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { NavSection } from "./NavSection";
export { PlaygroundSidebar } from "./PlaygroundSidebar";
export { Sidebar } from "./Sidebar";
export { SidebarCollapseButton } from "./SidebarCollapseButton";
export { SidebarHeader } from "./SidebarHeader";

View file

@ -2,11 +2,12 @@
import {
AlarmClock,
FilePlus2,
type LucideIcon,
Search,
MessagesSquare,
Radar,
Settings2,
WandSparkles,
Workflow,
X,
} from "lucide-react";
import { memo, useCallback, useState } from "react";
@ -20,8 +21,9 @@ interface ChatExamplePromptsProps {
}
const CATEGORY_ICONS: Record<string, LucideIcon> = {
search: Search,
create: FilePlus2,
monitor: Radar,
listen: MessagesSquare,
workflows: Workflow,
automate: AlarmClock,
tools: Settings2,
};