mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-10 22:32:16 +02:00
feat: api playground, other tweaks
This commit is contained in:
parent
7bf559cd5b
commit
50f2d095aa
104 changed files with 5482 additions and 244 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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: $89–149/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">
|
||||
|
|
|
|||
86
surfsense_web/components/homepage/persona-paths.tsx
Normal file
86
surfsense_web/components/homepage/persona-paths.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue