Merge remote-tracking branch 'upstream/dev' into fix/models

This commit is contained in:
Anish Sarkar 2026-07-25 00:32:12 +05:30
commit a2d5467023
1952 changed files with 103406 additions and 35663 deletions

View file

@ -5,7 +5,7 @@
# Optional packaged-client override. Leave unset in Docker so browser requests
# use same-origin relative URLs behind Caddy. Set it for packaged clients
# (e.g. Electron) or local dev that talks to a separate backend origin.
# NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000
NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000
# Server-only. Internal backend URL used by Next.js server code (RSC / route
# handlers). Cannot be a relative URL.
@ -40,7 +40,7 @@ NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
# Zero cache (real-time sync). Leave unset in Docker to use the same-origin
# "/zero" endpoint behind Caddy. Set it for local dev or packaged clients.
# ─────────────────────────────────────────────────────────────────────────────
# NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:4848
NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:4848
# Server-only shared secret that authorizes zero-cache when it calls
# /api/zero/query. Leave unset during the compatibility rollout, then set it
# once every zero-cache instance sends X-Api-Key.

View file

@ -0,0 +1,94 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ConnectorPage } from "@/components/connectors-marketing/connector-page";
import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld";
import { getAllConnectorSlugs, getConnector } from "@/lib/connectors-marketing";
interface PageProps {
params: Promise<{ slug: string }>;
}
// Only the known connector slugs are served; every other path falls through to 404.
export const dynamicParams = false;
export function generateStaticParams() {
return getAllConnectorSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const content = getConnector(slug);
if (!content) return { title: "Connector Not Found | SurfSense" };
const canonicalUrl = `https://www.surfsense.com/${content.slug}`;
return {
title: content.metaTitle,
description: content.metaDescription,
keywords: content.keywords,
alternates: { canonical: canonicalUrl },
openGraph: {
title: content.metaTitle,
description: content.metaDescription,
url: canonicalUrl,
siteName: "SurfSense",
type: "website",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: `${content.name} Scraper API on SurfSense`,
},
],
},
twitter: {
card: "summary_large_image",
title: content.metaTitle,
description: content.metaDescription,
images: ["/og-image.png"],
},
};
}
export default async function ConnectorMarketingPage({ params }: PageProps) {
const { slug } = await params;
const content = getConnector(slug);
if (!content) notFound();
const canonicalUrl = `https://www.surfsense.com/${content.slug}`;
return (
<>
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: `${content.name} Scraper API`,
applicationCategory: "DeveloperApplication",
operatingSystem: "Web, API",
description: content.metaDescription,
url: canonicalUrl,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
description: "Free tier included",
},
provider: {
"@type": "Organization",
name: "SurfSense",
url: "https://www.surfsense.com",
},
isPartOf: {
"@type": "WebSite",
name: "SurfSense",
url: "https://www.surfsense.com",
},
}}
/>
<FAQJsonLd questions={content.faq} />
<ConnectorPage content={content} />
</>
);
}

View file

@ -10,7 +10,7 @@ import { useAnnouncements } from "@/hooks/use-announcements";
// ---------------------------------------------------------------------------
export default function AnnouncementsPage() {
const { announcements, markAllRead } = useAnnouncements();
const { announcements, markAllRead } = useAnnouncements({ includeExpired: true });
// Auto-mark all visible announcements as read when the page is opened
useEffect(() => {

View file

@ -0,0 +1,117 @@
import { ArrowRight, Plug, Server } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { getAllConnectors } from "@/lib/connectors-marketing";
const canonicalUrl = "https://www.surfsense.com/connectors";
const metaDescription =
"Platform-native scraper APIs for AI agents. Pull live, structured data from the platforms where answers live through one typed API or the SurfSense MCP server. Explore every connector.";
export const metadata: Metadata = {
title: "Scraper APIs for AI Agents: All Connectors | SurfSense",
description: metaDescription,
keywords: [
"scraper api",
"web scraping api",
"scraper api for ai agents",
"data connectors",
"mcp server",
"open web research platform",
],
alternates: { canonical: canonicalUrl },
openGraph: {
title: "Scraper APIs for AI Agents: All Connectors | SurfSense",
description: metaDescription,
url: canonicalUrl,
siteName: "SurfSense",
type: "website",
images: [{ url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense connectors" }],
},
};
export default function ConnectorsIndexPage() {
const connectors = getAllConnectors();
return (
<div className="pt-28 pb-16 sm:pt-32">
<div className="mx-auto w-full max-w-7xl px-2 md:px-8 xl:px-0">
<header className="max-w-2xl">
<h1 className="text-3xl font-bold tracking-tight text-balance sm:text-4xl lg:text-5xl">
Connectors for every platform where answers live
</h1>
<p className="mt-5 text-base leading-relaxed text-muted-foreground sm:text-lg">
Each connector is a platform-native scraper API your AI agents can call directly, or
through the SurfSense MCP server. They are the live data behind the SurfSense{" "}
<Link href="/" className="font-medium text-foreground underline underline-offset-4">
open web research platform
</Link>
.
</p>
</header>
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{connectors.map((connector) => {
const Icon = connector.icon;
return (
<Link
key={connector.slug}
href={`/${connector.slug}`}
className="group flex flex-col rounded-xl border bg-card p-6 transition-colors hover:border-brand/40"
>
<span className="flex size-11 items-center justify-center rounded-lg border bg-muted/40 transition-transform duration-200 ease-out group-hover:scale-110 motion-reduce:transition-none motion-reduce:group-hover:scale-100">
<Icon className="size-5 text-foreground" />
</span>
<h2 className="mt-4 text-lg font-semibold">
{connector.cardTitle ?? `${connector.name} API`}
</h2>
<p className="mt-2 flex-1 text-sm leading-relaxed text-muted-foreground line-clamp-4">
{connector.heroLede}
</p>
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-foreground">
Explore
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
</span>
</Link>
);
})}
{/* Bespoke pages (not in the scrape-API registry): the two MCP directions. */}
<Link
href="/mcp-server"
className="group flex flex-col rounded-xl border bg-card p-6 transition-colors hover:border-brand/40"
>
<span className="flex size-11 items-center justify-center rounded-lg border bg-muted/40 transition-transform duration-200 ease-out group-hover:scale-110 motion-reduce:transition-none motion-reduce:group-hover:scale-100">
<Server className="size-5 text-foreground" />
</span>
<h2 className="mt-4 text-lg font-semibold">SurfSense MCP Server</h2>
<p className="mt-2 flex-1 text-sm leading-relaxed text-muted-foreground line-clamp-4">
Give Claude, Cursor, or any MCP client native tools for your workspace: every scraper
API plus knowledge base search, reads, and writes. One API key.
</p>
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-foreground">
Explore
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
</span>
</Link>
<Link
href="/external-mcp-connectors"
className="group flex flex-col rounded-xl border bg-card p-6 transition-colors hover:border-brand/40"
>
<span className="flex size-11 items-center justify-center rounded-lg border bg-muted/40 transition-transform duration-200 ease-out group-hover:scale-110 motion-reduce:transition-none motion-reduce:group-hover:scale-100">
<Plug className="size-5 text-foreground" />
</span>
<h2 className="mt-4 text-lg font-semibold">External MCP Connectors</h2>
<p className="mt-2 flex-1 text-sm leading-relaxed text-muted-foreground line-clamp-4">
Bring any MCP server to your agents. Paste a config like you would in Cursor, tools
are auto-discovered, and Notion, Slack, Jira, and more connect with one-click OAuth.
</p>
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-foreground">
Explore
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
</span>
</Link>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,380 @@
import { IconBrandGithub } from "@tabler/icons-react";
import { ArrowRight, Check, Plug, ShieldCheck, Wrench } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { ConnectorFaq } from "@/components/connectors-marketing/connector-faq";
import { Reveal } from "@/components/connectors-marketing/reveal";
import { MarketingSection } from "@/components/marketing/section";
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type { FaqItem } from "@/lib/connectors-marketing/types";
const canonicalUrl = "https://www.surfsense.com/external-mcp-connectors";
const metaDescription =
"External MCP connectors let your SurfSense agents use any MCP server. Paste a config, tools are auto-discovered, and every call runs with per-tool approval. Try it free.";
export const metadata: Metadata = {
title: "External MCP Connectors: Add Any MCP Server | SurfSense",
description: metaDescription,
keywords: [
"mcp connector",
"external mcp connectors",
"what is an mcp connector",
"mcp client",
"add mcp server",
"connect mcp server",
"mcp integrations",
],
alternates: { canonical: canonicalUrl },
openGraph: {
title: "External MCP Connectors: Add Any MCP Server | SurfSense",
description: metaDescription,
url: canonicalUrl,
siteName: "SurfSense",
type: "website",
images: [
{ url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense external MCP connectors" },
],
},
twitter: {
card: "summary_large_image",
title: "External MCP Connectors: Add Any MCP Server | SurfSense",
description: metaDescription,
images: ["/og-image.png"],
},
};
/* Mirrors the real server_config contract (stdio + HTTP transports). */
const STDIO_CONFIG = `{
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
"env": { "LOG_LEVEL": "info" },
"transport": "stdio"
}`;
const HTTP_CONFIG = `{
"url": "https://mcp.example.com/mcp",
"headers": { "Authorization": "Bearer <token>" },
"transport": "streamable-http"
}`;
const STEPS = [
{
icon: Plug,
title: "Paste a server config",
description:
"Add any MCP server the same way you would in Cursor: a local command for stdio servers, or a URL and headers for remote HTTP and SSE servers.",
},
{
icon: Wrench,
title: "Tools are auto-discovered",
description:
"SurfSense tests the connection and pulls the full tool list from the server. No manual tool configuration, no schema files to maintain.",
},
{
icon: ShieldCheck,
title: "Your agent uses them, safely",
description:
"Read-only tools run automatically. Anything that writes asks for your approval first, and you can trust a tool once to always allow it.",
},
] as const;
/** Hosted MCP apps with one-click OAuth (mirrors the backend MCP service registry). */
const ONE_CLICK_APPS = [
"Notion",
"Slack",
"Jira",
"Confluence",
"Linear",
"ClickUp",
"Airtable",
] as const;
const FAQ: FaqItem[] = [
{
question: "What is an external MCP connector?",
answer:
"An external MCP connector links SurfSense to an outside MCP (Model Context Protocol) server, so your agents can call that server's tools. You add a server config once, its tools are auto-discovered, and every agent in your workspace can use them with per-tool approval.",
},
{
question: "How is this different from the SurfSense MCP server?",
answer:
"Direction. External MCP connectors make SurfSense the client: outside tools flow into your SurfSense agents. The SurfSense MCP server is the reverse: it exposes your workspace and the scraper APIs as tools inside Claude, Cursor, or any MCP client you already run.",
},
{
question: "Which MCP transports are supported?",
answer:
"All the common ones. Local stdio servers run as a process with a command, args, and environment variables. Remote servers connect over streamable HTTP, plain HTTP, or SSE with a URL and optional headers, which covers hosted MCP servers that require an auth token.",
},
{
question: "Is it safe to give an agent MCP tools?",
answer:
"Every MCP tool runs through SurfSense's permission layer. Read-only tools are allowed automatically, while any tool that can write or act asks for your approval before it executes. You can mark tools you rely on as trusted so they skip the prompt on later calls.",
},
{
question: "Can I connect Notion or Slack without writing a config?",
answer:
"Yes. Notion, Slack, Jira, Confluence, Linear, ClickUp, and Airtable connect through their official hosted MCP servers with one-click OAuth. SurfSense handles the token exchange and curates each app's tool list, so you sign in once and your agents can use them immediately.",
},
];
function ConfigCard() {
return (
<div className="rounded-xl border bg-card p-5 shadow-sm">
<p className="font-mono text-xs text-muted-foreground">Local server (stdio)</p>
<pre className="mt-2 overflow-x-auto rounded-lg bg-muted/50 p-4 font-mono text-xs leading-relaxed">
{STDIO_CONFIG}
</pre>
<p className="mt-4 font-mono text-xs text-muted-foreground">Remote server (HTTP / SSE)</p>
<pre className="mt-2 overflow-x-auto rounded-lg bg-muted/50 p-4 font-mono text-xs leading-relaxed">
{HTTP_CONFIG}
</pre>
<p className="mt-3 flex items-center gap-1.5 text-xs text-muted-foreground">
<Check className="size-3.5 text-brand" aria-hidden />
Tools auto-discovered on connect
</p>
</div>
);
}
export default function ExternalMcpConnectorsPage() {
return (
<>
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "SurfSense External MCP Connectors",
applicationCategory: "DeveloperApplication",
operatingSystem: "Web",
description: metaDescription,
url: canonicalUrl,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
description: "Free tier included",
},
provider: {
"@type": "Organization",
name: "SurfSense",
url: "https://www.surfsense.com",
},
isPartOf: { "@type": "WebSite", name: "SurfSense", url: "https://www.surfsense.com" },
}}
/>
<FAQJsonLd questions={FAQ} />
<div className="pb-4">
{/* Hero */}
<MarketingSection className="pt-28 pb-12 sm:pt-32 sm:pb-16">
<div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-14">
<div>
<BreadcrumbNav
className="mb-6"
items={[
{ name: "Connectors", href: "/connectors" },
{ name: "External MCP Connectors", href: "/external-mcp-connectors" },
]}
/>
<Badge variant="outline" className="mb-5 gap-1.5 py-1">
<Plug className="size-3.5" />
External MCP connectors
</Badge>
<h1 className="text-3xl font-bold tracking-tight text-balance sm:text-4xl lg:text-5xl">
Bring any external MCP server to your agents
</h1>
<p className="mt-5 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">
External MCP connectors turn your SurfSense workspace into an MCP client. Add any
MCP server with the same config you'd use in Cursor, and its tools are
auto-discovered and handed to your agents, guarded by per-tool approval. Notion,
Slack, Jira, and more connect with one-click OAuth.
</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Button asChild size="lg">
<Link href="/register">
Start for free
<ArrowRight className="size-4" />
</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link href="/docs">Read the docs</Link>
</Button>
<Button asChild variant="ghost" size="lg">
<Link
href="https://github.com/MODSetter/SurfSense"
target="_blank"
rel="noopener noreferrer"
>
<IconBrandGithub className="size-4" />
GitHub
</Link>
</Button>
</div>
</div>
<ConfigCard />
</div>
</MarketingSection>
{/* How it works */}
<MarketingSection>
<Reveal>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
From config to agent tool in three steps
</h2>
</Reveal>
<div className="mt-8 grid gap-4 sm:grid-cols-3">
{STEPS.map((step) => (
<Reveal key={step.title}>
<div className="h-full rounded-xl border bg-card p-6 transition-colors hover:border-brand/40">
<span className="flex size-10 items-center justify-center rounded-lg border bg-muted/40">
<step.icon className="size-5 text-foreground" aria-hidden />
</span>
<h3 className="mt-4 font-semibold">{step.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{step.description}
</p>
</div>
</Reveal>
))}
</div>
</MarketingSection>
{/* One-click apps */}
<MarketingSection>
<Reveal>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
Your work apps, no config required
</h2>
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
These apps run on their official hosted MCP servers. Sign in once with OAuth and
SurfSense manages the tokens and curates each tool list, so your agents can search
Notion, read Slack threads, or file Jira issues alongside your live web research.
</p>
</Reveal>
<Reveal>
<div className="mt-8 flex flex-wrap gap-2">
{ONE_CLICK_APPS.map((app) => (
<span
key={app}
className="inline-flex items-center gap-1.5 rounded-full border bg-card px-4 py-2 text-sm font-medium"
>
<Check className="size-3.5 text-brand" aria-hidden />
{app}
</span>
))}
</div>
</Reveal>
</MarketingSection>
{/* Connector vs server */}
<MarketingSection>
<Reveal>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
External MCP connectors vs the SurfSense MCP server
</h2>
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
They are two sides of the same protocol. The external MCP connectors on this page make
SurfSense a <em>client</em>: they consume tools from outside MCP servers. The{" "}
<Link
href="/mcp-server"
className="font-medium text-foreground underline underline-offset-4"
>
SurfSense MCP server
</Link>{" "}
does the reverse, exposing your workspace and scraper APIs like{" "}
<Link
href="/reddit"
className="font-medium text-foreground underline underline-offset-4"
>
Reddit
</Link>{" "}
and{" "}
<Link
href="/google-maps"
className="font-medium text-foreground underline underline-offset-4"
>
Google Maps
</Link>{" "}
as native tools inside Claude, Cursor, or any agent you already run. Use both and data
flows in either direction.
</p>
</Reveal>
</MarketingSection>
{/* FAQ */}
<MarketingSection>
<Reveal>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
External MCP connectors: frequently asked questions
</h2>
</Reveal>
<Reveal>
<div className="mt-6 max-w-3xl">
<ConnectorFaq items={FAQ} />
</div>
</Reveal>
</MarketingSection>
{/* Closing CTA + related */}
<MarketingSection>
<Reveal>
<div className="rounded-2xl border bg-card p-8 text-center sm:p-12">
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
Give your agents every tool they need
</h2>
<p className="mx-auto mt-3 max-w-xl text-muted-foreground leading-relaxed">
External MCP connectors are part of the SurfSense{" "}
<Link href="/" className="font-medium text-foreground underline underline-offset-4">
open web research platform
</Link>
. Start free, no credit card required.
</p>
<div className="mt-7 flex flex-wrap justify-center gap-3">
<Button asChild size="lg">
<Link href="/register">
Start for free
<ArrowRight className="size-4" />
</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link href="/pricing">See pricing</Link>
</Button>
</div>
<Separator className="my-8" />
<nav aria-label="Other connectors" className="flex flex-wrap justify-center gap-2">
<Button asChild variant="ghost" size="sm">
<Link href="/connectors">All connectors</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/mcp-server">SurfSense MCP Server</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/reddit">Reddit API</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/youtube">YouTube API</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/google-maps">Google Maps API</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/google-search">SERP API</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/web-crawl">Web Crawl API</Link>
</Button>
</nav>
</div>
</Reveal>
</MarketingSection>
</div>
</>
);
}

View file

@ -13,7 +13,7 @@ import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
import { getPostLoginRedirectPath } from "@/lib/auth-utils";
import { ValidationError } from "@/lib/error";
import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events";
import { trackLoginAttempt, trackLoginFailure } from "@/lib/posthog/events";
export function LocalLoginForm() {
const t = useTranslations("auth");
@ -45,8 +45,8 @@ export function LocalLoginForm() {
grant_type: "password",
});
// Track successful login
trackLoginSuccess("local");
// auth_login_success is now emitted server-side
// (UserManager.on_after_login) — authoritative vs. optimistic.
// Small delay to show success message
setTimeout(() => {

View file

@ -0,0 +1,433 @@
import { IconBrandGithub } from "@tabler/icons-react";
import { ArrowRight, Check, Database, KeyRound, Server, TerminalSquare } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { ConnectorFaq } from "@/components/connectors-marketing/connector-faq";
import { Reveal } from "@/components/connectors-marketing/reveal";
import { MarketingSection } from "@/components/marketing/section";
import { AgentSetupTabs } from "@/components/mcp/agent-setup-tabs";
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type { FaqItem } from "@/lib/connectors-marketing/types";
const canonicalUrl = "https://www.surfsense.com/mcp-server";
const metaDescription =
"The SurfSense MCP server gives Claude, Cursor, and any MCP client native tools for your workspace: scrape Reddit, YouTube, Instagram, TikTok, Amazon, Walmart, Google Maps, Google Search, and the web, plus full knowledge base access. One API key.";
export const metadata: Metadata = {
title: "SurfSense MCP Server: Scraper APIs and Knowledge Base as Agent Tools",
description: metaDescription,
keywords: [
"surfsense mcp server",
"mcp server",
"mcp server for web scraping",
"reddit mcp server",
"youtube mcp server",
"google maps mcp server",
"serp mcp server",
"mcp server for claude",
"mcp server for cursor",
"knowledge base mcp server",
],
alternates: { canonical: canonicalUrl },
openGraph: {
title: "SurfSense MCP Server: Scraper APIs and Knowledge Base as Agent Tools",
description: metaDescription,
url: canonicalUrl,
siteName: "SurfSense",
type: "website",
images: [{ url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense MCP server" }],
},
twitter: {
card: "summary_large_image",
title: "SurfSense MCP Server: Scraper APIs and Knowledge Base as Agent Tools",
description: metaDescription,
images: ["/og-image.png"],
},
};
/* The hosted Cursor config; mirrors lib/mcp/clients.ts. */
const CURSOR_CONFIG = `{
"mcpServers": {
"surfsense": {
"url": "https://mcp.surfsense.com/mcp",
"headers": {
"Authorization": "Bearer ss_pat_..."
}
}
}
}`;
const STEPS = [
{
icon: KeyRound,
title: "Create an API key",
description:
"In SurfSense, go to Settings, then API, and create a key. Enable API access on the workspaces you want your agents to reach. That key is all the server needs.",
},
{
icon: TerminalSquare,
title: "Add the server to your client",
description:
"Point your client at https://mcp.surfsense.com/mcp with your key in an Authorization header — the hosted config for Cursor, Claude Code, and others is one paste. Prefer stdio? Switch to Self-host and run it against your own backend.",
},
{
icon: Server,
title: "Your agent has the tools",
description:
"Every scraper and knowledge base operation shows up as a native, typed MCP tool. Your agent picks a workspace once and the server carries the context between calls.",
},
] as const;
/** Mirrors the tool registry in surfsense_mcp (see its README). */
const TOOL_GROUPS = [
{
icon: Server,
title: "Live scrapers",
description: "Structured, current platform data. One returned item is one billable unit.",
tools: [
"surfsense_reddit_scrape",
"surfsense_youtube_scrape",
"surfsense_youtube_comments",
"surfsense_instagram_scrape",
"surfsense_instagram_details",
"surfsense_tiktok_scrape",
"surfsense_tiktok_comments",
"surfsense_tiktok_user_search",
"surfsense_tiktok_trending",
"surfsense_google_maps_scrape",
"surfsense_google_maps_reviews",
"surfsense_google_search",
"surfsense_amazon_scrape",
"surfsense_walmart_scrape",
"surfsense_walmart_reviews",
"surfsense_web_crawl",
"surfsense_list_scraper_runs",
"surfsense_get_scraper_run",
],
},
{
icon: Database,
title: "Knowledge base",
description: "Read and write the same knowledge base your SurfSense agents use.",
tools: [
"surfsense_search_knowledge_base",
"surfsense_list_documents",
"surfsense_get_document",
"surfsense_add_document",
"surfsense_upload_file",
"surfsense_update_document",
"surfsense_delete_document",
],
},
{
icon: KeyRound,
title: "Workspace selector",
description: "Pick a workspace once; every later call defaults to it.",
tools: ["surfsense_list_workspaces", "surfsense_select_workspace"],
},
] as const;
const FAQ: FaqItem[] = [
{
question: "What is the SurfSense MCP server?",
answer:
"It is a Model Context Protocol server that exposes your SurfSense workspace to MCP clients like Claude Code, Cursor, and Claude Desktop. Your agents get native tools for every scraper API (Reddit, YouTube, Instagram, TikTok, Amazon, Walmart, Google Maps, Google Search, web crawl) and for searching, reading, and writing your knowledge base.",
},
{
question: "Which MCP clients does it work with?",
answer:
"Any MCP client that speaks remote (streamable HTTP) or stdio. Claude Code, Codex, OpenCode, Cursor, Claude Desktop, VS Code, Windsurf, and Gemini CLI all have copy-paste configs on this page — Hosted for the one-paste https://mcp.surfsense.com/mcp endpoint, or Self-host for stdio against your own backend.",
},
{
question: "How is usage billed?",
answer:
"Exactly like the REST API, because the server is a thin layer over it. Scraper tools consume the same pay-as-you-go credits, priced per returned item, and knowledge base operations work within your plan. New accounts start with $5 of free credit.",
},
{
question: "Does it work with a self-hosted SurfSense?",
answer:
"Yes. The server talks to SurfSense purely over its REST API and imports no backend code, so pointing SURFSENSE_BASE_URL at your own instance is all it takes. It works with the cloud at api.surfsense.com the same way.",
},
{
question: "How does the agent know which workspace to use?",
answer:
"The server ships a workspace selector: the agent lists the workspaces your API key can access, selects one by name, and every later call defaults to it. Any tool also accepts a workspace override for a single call, and ids never need to be typed by hand.",
},
];
function ConfigCard() {
return (
<div className="rounded-xl border bg-card p-5 shadow-sm">
<p className="font-mono text-xs text-muted-foreground">.cursor/mcp.json</p>
<pre className="mt-2 overflow-x-auto rounded-lg bg-muted/50 p-4 font-mono text-xs leading-relaxed">
{CURSOR_CONFIG}
</pre>
<p className="mt-3 flex items-center gap-1.5 text-xs text-muted-foreground">
<Check className="size-3.5 text-brand" aria-hidden />
Works with Claude Code, Cursor, Claude Desktop, and any MCP client
</p>
</div>
);
}
export default function McpServerPage() {
return (
<>
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "SurfSense MCP Server",
applicationCategory: "DeveloperApplication",
operatingSystem: "Web",
description: metaDescription,
url: canonicalUrl,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
description: "$5 free credit included, pay as you go",
},
provider: {
"@type": "Organization",
name: "SurfSense",
url: "https://www.surfsense.com",
},
isPartOf: { "@type": "WebSite", name: "SurfSense", url: "https://www.surfsense.com" },
}}
/>
<FAQJsonLd questions={FAQ} />
<div className="pb-4">
{/* Hero */}
<MarketingSection className="pt-28 pb-12 sm:pt-32 sm:pb-16">
<div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-14">
<div>
<BreadcrumbNav
className="mb-6"
items={[
{ name: "Connectors", href: "/connectors" },
{ name: "SurfSense MCP Server", href: "/mcp-server" },
]}
/>
<Badge variant="outline" className="mb-5 gap-1.5 py-1">
<Server className="size-3.5" />
SurfSense MCP server
</Badge>
<h1 className="text-3xl font-bold tracking-tight text-balance sm:text-4xl lg:text-5xl">
Give your agents SurfSense as native tools
</h1>
<p className="mt-5 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">
The SurfSense MCP server hands Claude, Cursor, or any MCP client the whole platform:
scrape Reddit, YouTube, Instagram, TikTok, Amazon, Walmart, Google Maps, Google
Search, and the open web, and search, read, and write your knowledge base. One API
key, typed tools, pay as you go.
</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Button asChild size="lg">
<Link href="/register">
Get your API key
<ArrowRight className="size-4" />
</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link href="/docs">Read the docs</Link>
</Button>
<Button asChild variant="ghost" size="lg">
<Link
href="https://github.com/MODSetter/SurfSense"
target="_blank"
rel="noopener noreferrer"
>
<IconBrandGithub className="size-4" />
GitHub
</Link>
</Button>
</div>
</div>
<ConfigCard />
</div>
</MarketingSection>
{/* How it works */}
<MarketingSection>
<Reveal>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
From API key to agent tools in three steps
</h2>
</Reveal>
<div className="mt-8 grid gap-4 sm:grid-cols-3">
{STEPS.map((step) => (
<Reveal key={step.title}>
<div className="h-full rounded-xl border bg-card p-6 transition-colors hover:border-brand/40">
<span className="flex size-10 items-center justify-center rounded-lg border bg-muted/40">
<step.icon className="size-5 text-foreground" aria-hidden />
</span>
<h3 className="mt-4 font-semibold">{step.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{step.description}
</p>
</div>
</Reveal>
))}
</div>
</MarketingSection>
{/* Per-agent setup */}
<MarketingSection>
<Reveal>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
Step-by-step setup for every agent
</h2>
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
Pick your client, choose <strong>Hosted</strong> or <strong>Self-host</strong>, and
paste the config. Replace the key with one from API Playground API Keys or grab a
pre-filled config from the playground itself.
</p>
</Reveal>
<Reveal>
<div className="mt-8 rounded-xl border bg-card p-5 shadow-sm sm:p-6">
<AgentSetupTabs />
</div>
</Reveal>
</MarketingSection>
{/* Tools */}
<MarketingSection>
<Reveal>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
Every tool the server exposes
</h2>
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
The server is a thin layer over the SurfSense REST API: the same endpoints, the same
billing, no backend code imported. Whatever ships in the API shows up here.
</p>
</Reveal>
<div className="mt-8 grid gap-4 lg:grid-cols-3">
{TOOL_GROUPS.map((group) => (
<Reveal key={group.title}>
<div className="h-full rounded-xl border bg-card p-6">
<span className="flex size-10 items-center justify-center rounded-lg border bg-muted/40">
<group.icon className="size-5 text-foreground" aria-hidden />
</span>
<h3 className="mt-4 font-semibold">{group.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{group.description}
</p>
<ul className="mt-4 space-y-1.5">
{group.tools.map((tool) => (
<li key={tool} className="truncate font-mono text-xs text-muted-foreground">
{tool}
</li>
))}
</ul>
</div>
</Reveal>
))}
</div>
</MarketingSection>
{/* Server vs external connectors */}
<MarketingSection>
<Reveal>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
The SurfSense MCP server vs external MCP connectors
</h2>
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
They are two sides of the same protocol. The MCP <em>server</em> on this page pushes
SurfSense tools out to agents you already run in Claude, Cursor, or your own harness.{" "}
<Link
href="/external-mcp-connectors"
className="font-medium text-foreground underline underline-offset-4"
>
External MCP connectors
</Link>{" "}
do the reverse: they pull outside tools like Notion, Slack, and Jira into your
SurfSense agents. Use both and data flows in either direction.
</p>
</Reveal>
</MarketingSection>
{/* FAQ */}
<MarketingSection>
<Reveal>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
SurfSense MCP server: frequently asked questions
</h2>
</Reveal>
<Reveal>
<div className="mt-6 max-w-3xl">
<ConnectorFaq items={FAQ} />
</div>
</Reveal>
</MarketingSection>
{/* Closing CTA + related */}
<MarketingSection>
<Reveal>
<div className="rounded-2xl border bg-card p-8 text-center sm:p-12">
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
Put live web data inside your agents
</h2>
<p className="mx-auto mt-3 max-w-xl text-muted-foreground leading-relaxed">
The MCP server is part of the SurfSense{" "}
<Link href="/" className="font-medium text-foreground underline underline-offset-4">
open web research platform
</Link>
. Start with $5 of free credit, no credit card required.
</p>
<div className="mt-7 flex flex-wrap justify-center gap-3">
<Button asChild size="lg">
<Link href="/register">
Start for free
<ArrowRight className="size-4" />
</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link href="/pricing">See pricing</Link>
</Button>
</div>
<Separator className="my-8" />
<nav aria-label="Other connectors" className="flex flex-wrap justify-center gap-2">
<Button asChild variant="ghost" size="sm">
<Link href="/connectors">All connectors</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/external-mcp-connectors">External MCP Connectors</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/reddit">Reddit API</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/youtube">YouTube API</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/instagram">Instagram API</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/tiktok">TikTok API</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/google-maps">Google Maps API</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/google-search">SERP API</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/web-crawl">Web Crawl API</Link>
</Button>
</nav>
</div>
</Reveal>
</MarketingSection>
</div>
</>
);
}

View file

@ -1,29 +1,23 @@
import dynamic from "next/dynamic";
import { AuthRedirect } from "@/components/homepage/auth-redirect";
import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid";
import { FeaturesCards } from "@/components/homepage/features-card";
import { CommunityStrip } from "@/components/homepage/community-strip";
import { CompareTable } from "@/components/homepage/compare-table";
import { ConnectorGrid } from "@/components/homepage/connector-grid";
import { HeroSection } from "@/components/homepage/hero-section";
const WhySurfSense = dynamic(() =>
import("@/components/homepage/why-surfsense").then((m) => ({ default: m.WhySurfSense }))
);
const ExternalIntegrations = dynamic(() => import("@/components/homepage/integrations"));
const CTAHomepage = dynamic(() =>
import("@/components/homepage/cta").then((m) => ({ default: m.CTAHomepage }))
);
import { HomeFaq } from "@/components/homepage/home-faq";
import { LogoCloud } from "@/components/homepage/logo-cloud";
import { SocialProof } from "@/components/homepage/social-proof";
export default function HomePage() {
return (
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
<div className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
<AuthRedirect />
<HeroSection />
<WhySurfSense />
<FeaturesCards />
<FeaturesBentoGrid />
<ExternalIntegrations />
<CTAHomepage />
<LogoCloud />
<SocialProof />
<ConnectorGrid />
<CompareTable />
<HomeFaq />
<CommunityStrip />
</div>
);
}

View file

@ -1,18 +1,74 @@
import type { Metadata } from "next";
import PricingBasic from "@/components/pricing/pricing-section";
import { JsonLd } from "@/components/seo/json-ld";
const canonicalUrl = "https://www.surfsense.com/pricing";
const metaTitle = "SurfSense Pricing: Self-Host Free or Pay As You Go";
const metaDescription =
"Self-host SurfSense for free from our open-source repo, or use the cloud with $5 of free credit and pay as you go at provider cost. No subscription.";
export const metadata: Metadata = {
title: "Pricing | SurfSense - Free AI Workspace, Automations & Agents",
description:
"Explore SurfSense plans and pricing. Start free with 500 pages & $5 in premium credits. Run AI automations and agents, use ChatGPT, Claude AI, and premium AI models, and pay as you go at provider cost.",
title: metaTitle,
description: metaDescription,
keywords: [
"surfsense pricing",
"pay as you go ai platform",
"open source ai agent platform",
"self-hosted ai workspace",
"ai automation pricing",
"web scraping api pricing",
],
alternates: {
canonical: "https://www.surfsense.com/pricing",
canonical: canonicalUrl,
},
openGraph: {
title: metaTitle,
description: metaDescription,
url: canonicalUrl,
siteName: "SurfSense",
type: "website",
images: [{ url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense pricing" }],
},
twitter: {
card: "summary_large_image",
title: metaTitle,
description: metaDescription,
images: ["/og-image.png"],
},
};
const page = () => {
return (
<div>
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "SurfSense",
applicationCategory: "BusinessApplication",
operatingSystem: "Windows, macOS, Linux, Web",
url: canonicalUrl,
offers: [
{
"@type": "Offer",
name: "Free (Self-Hosted)",
price: "0",
priceCurrency: "USD",
description:
"Open source and self-hostable with unlimited usage. Bring your own model keys.",
},
{
"@type": "Offer",
name: "Pay As You Go",
price: "0",
priceCurrency: "USD",
description:
"Cloud accounts start with $5 of free credit. Top up any amount after that; $1 buys $1 of credit at provider cost with no subscription.",
},
],
}}
/>
<PricingBasic />
</div>
);

View file

@ -15,11 +15,7 @@ import { Spinner } from "@/components/ui/spinner";
import { useSession } from "@/hooks/use-session";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AppError, ValidationError } from "@/lib/error";
import {
trackRegistrationAttempt,
trackRegistrationFailure,
trackRegistrationSuccess,
} from "@/lib/posthog/events";
import { trackRegistrationAttempt, trackRegistrationFailure } from "@/lib/posthog/events";
import { AmbientBackground } from "../login/AmbientBackground";
export default function RegisterPage() {
@ -81,8 +77,8 @@ export default function RegisterPage() {
is_verified: false,
});
// Track successful registration
trackRegistrationSuccess();
// auth_registration_success is now emitted server-side
// (UserManager.on_after_register) — authoritative vs. optimistic.
// Success toast
toast.success(t("register_success"), {

View file

@ -1,220 +0,0 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import type React from "react";
import { useEffect, useState } from "react";
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
import {
globalLlmConfigStatusAtom,
globalModelConnectionsAtom,
modelConnectionsAtom,
modelRolesAtom,
} from "@/atoms/model-connections/model-connections-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
import { LayoutDataProvider } from "@/components/layout";
import { OnboardingTour } from "@/components/onboarding-tour";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useFolderSync } from "@/hooks/use-folder-sync";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { useElectronAPI } from "@/hooks/use-platform";
import { isLlmOnboardingComplete } from "@/lib/onboarding";
export function DashboardClientLayout({
children,
searchSpaceId,
}: {
children: React.ReactNode;
searchSpaceId: string;
}) {
const t = useTranslations("dashboard");
const router = useRouter();
const pathname = usePathname();
const { search_space_id } = useParams();
const activeSearchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom);
const setPendingUserImageUrls = useSetAtom(pendingUserImageDataUrlsAtom);
const { data: modelRoles = {}, isLoading: loading, error } = useAtomValue(modelRolesAtom);
const { data: globalConnections = [], isLoading: globalConfigsLoading } = useAtomValue(
globalModelConnectionsAtom
);
const { data: modelConnections = [], isLoading: modelConnectionsLoading } =
useAtomValue(modelConnectionsAtom);
const { data: globalConfigStatus, isLoading: globalConfigStatusLoading } =
useAtomValue(globalLlmConfigStatusAtom);
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);
const isOnboardingPage = pathname?.includes("/onboard");
const isOwner = access?.is_owner ?? false;
const isSearchSpaceReady = activeSearchSpaceId === searchSpaceId;
useEffect(() => {
if (isSearchSpaceReady) return;
setHasCheckedOnboarding(false);
}, [isSearchSpaceReady]);
useEffect(() => {
if (isOnboardingPage) {
setHasCheckedOnboarding(true);
return;
}
if (
isSearchSpaceReady &&
!loading &&
!accessLoading &&
!globalConfigsLoading &&
!globalConfigStatusLoading &&
!modelConnectionsLoading &&
!hasCheckedOnboarding
) {
// Onboarding is only relevant when no operator-provided
// global_llm_config.yaml exists. When it does, search spaces inherit
// the global config and should never be forced into onboarding.
if (globalConfigStatus?.exists) {
setHasCheckedOnboarding(true);
return;
}
const onboardingComplete = isLlmOnboardingComplete(
modelRoles.chat_model_id,
globalConnections,
modelConnections
);
if (onboardingComplete) {
setHasCheckedOnboarding(true);
return;
}
if (!isOwner) {
setHasCheckedOnboarding(true);
return;
}
router.push(`/dashboard/${searchSpaceId}/onboard`);
setHasCheckedOnboarding(true);
}
}, [
isSearchSpaceReady,
loading,
accessLoading,
globalConfigsLoading,
globalConfigStatusLoading,
globalConfigStatus,
modelConnectionsLoading,
modelRoles.chat_model_id,
globalConnections,
modelConnections,
isOnboardingPage,
isOwner,
router,
searchSpaceId,
hasCheckedOnboarding,
]);
const electronAPI = useElectronAPI();
useEffect(() => {
const htmlBackground = document.documentElement.style.backgroundColor;
const bodyBackground = document.body.style.backgroundColor;
document.documentElement.style.backgroundColor = "var(--panel)";
document.body.style.backgroundColor = "var(--panel)";
return () => {
document.documentElement.style.backgroundColor = htmlBackground;
document.body.style.backgroundColor = bodyBackground;
};
}, []);
useEffect(() => {
if (!electronAPI?.onChatScreenCapture) return;
return electronAPI.onChatScreenCapture((dataUrl: string) => {
if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:image/")) return;
setPendingUserImageUrls((prev) => [...prev, dataUrl]);
});
}, [electronAPI, setPendingUserImageUrls]);
useEffect(() => {
const activeSeacrhSpaceId =
typeof search_space_id === "string"
? search_space_id
: Array.isArray(search_space_id) && search_space_id.length > 0
? search_space_id[0]
: "";
if (!activeSeacrhSpaceId) return;
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
// Sync to Electron store if stored value is null (first navigation)
if (electronAPI?.getActiveSearchSpace && electronAPI.setActiveSearchSpace) {
const setActiveSearchSpace = electronAPI.setActiveSearchSpace;
electronAPI
.getActiveSearchSpace()
.then((stored: string | null) => {
if (!stored) {
setActiveSearchSpace(activeSeacrhSpaceId);
}
})
.catch(() => {});
}
}, [search_space_id, setActiveSearchSpaceIdState, electronAPI]);
// Determine if we should show loading
const shouldShowLoading =
!hasCheckedOnboarding &&
(!isSearchSpaceReady ||
loading ||
accessLoading ||
globalConfigsLoading ||
globalConfigStatusLoading ||
modelConnectionsLoading) &&
!isOnboardingPage;
// Use global loading screen - spinner animation won't reset
useGlobalLoadingEffect(shouldShowLoading);
// Wire desktop app file watcher -> single-file re-index API
useFolderSync();
if (shouldShowLoading) {
return null;
}
if (error && !hasCheckedOnboarding && !isOnboardingPage) {
return (
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium text-destructive">
{t("config_error")}
</CardTitle>
<CardDescription>{t("failed_load_llm_config")}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{error instanceof Error ? error.message : String(error)}
</p>
</CardContent>
</Card>
</div>
);
}
if (isOnboardingPage) {
return <>{children}</>;
}
return (
<DocumentUploadDialogProvider>
<OnboardingTour />
<LayoutDataProvider searchSpaceId={searchSpaceId}>{children}</LayoutDataProvider>
</DocumentUploadDialogProvider>
);
}

View file

@ -1,16 +0,0 @@
// Server component
import type React from "react";
import { use } from "react";
import { DashboardClientLayout } from "./client-layout";
export default function DashboardLayout({
params,
children,
}: {
params: Promise<{ search_space_id: string }>;
children: React.ReactNode;
}) {
const { search_space_id } = use(params);
return <DashboardClientLayout searchSpaceId={search_space_id}>{children}</DashboardClientLayout>;
}

View file

@ -1,62 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<div
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-main-panel"
style={{
["--thread-max-width" as string]: "42rem",
}}
>
<div
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 scroll-smooth"
style={{ scrollbarGutter: "stable" }}
>
<div
aria-hidden
className="aui-chat-viewport-top-fade pointer-events-none sticky top-0 z-10 -mx-4 h-2 shrink-0 bg-gradient-to-b from-main-panel from-20% to-transparent"
/>
<div className="mx-auto w-full max-w-(--thread-max-width) flex flex-1 flex-col gap-6 py-8">
{/* User message */}
<div className="flex justify-end">
<Skeleton className="h-12 w-56 rounded-2xl" />
</div>
{/* Assistant message */}
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-18 w-[40%]" />
</div>
{/* User message */}
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-72 rounded-2xl" />
</div>
{/* Assistant message */}
<div className="flex flex-col gap-2">
<Skeleton className="h-10 w-[30%]" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-6 w-[60%]" />
</div>
{/* User message */}
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-96 rounded-2xl" />
</div>
</div>
{/* Input bar */}
<div
className="aui-chat-composer-footer sticky bottom-0 z-20 -mx-4 mt-auto flex flex-col items-stretch bg-gradient-to-t from-main-panel from-60% to-transparent px-4 pt-6"
style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}
>
<div className="aui-chat-composer-area relative mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-3 overflow-visible">
<Skeleton className="h-28 w-full rounded-3xl" />
</div>
</div>
</div>
</div>
);
}

View file

@ -1,94 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
import {
globalLlmConfigStatusAtom,
globalModelConnectionsAtom,
modelConnectionsAtom,
modelRolesAtom,
} from "@/atoms/model-connections/model-connections-query.atoms";
import { Logo } from "@/components/Logo";
import { ModelProviderConnectionsPanel } from "@/components/settings/model-connections/model-provider-connections-panel";
import { Button } from "@/components/ui/button";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { useSession } from "@/hooks/use-session";
import { redirectToLogin } from "@/lib/auth-utils";
import { hasEnabledChatModel, isLlmOnboardingComplete } from "@/lib/onboarding";
export default function OnboardPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
const session = useSession();
const { data: globalConnections = [], isLoading: globalLoading } = useAtomValue(
globalModelConnectionsAtom
);
const { data: connections = [] } = useAtomValue(modelConnectionsAtom);
const { data: roles = {}, isLoading: rolesLoading } = useAtomValue(modelRolesAtom);
const { data: globalConfigStatus, isLoading: globalConfigStatusLoading } =
useAtomValue(globalLlmConfigStatusAtom);
useEffect(() => {
if (session.status === "unauthenticated") redirectToLogin();
}, [session.status]);
const hasUsableChatModel = useMemo(
() => hasEnabledChatModel([...globalConnections, ...connections]),
[globalConnections, connections]
);
const onboardingComplete = isLlmOnboardingComplete(
roles.chat_model_id,
globalConnections,
connections
);
const isLoading =
session.status === "loading" || globalLoading || rolesLoading || globalConfigStatusLoading;
// Onboarding only applies when no global_llm_config.yaml exists. If a global
// config is present (or onboarding is already complete), leave this page.
const shouldLeaveOnboarding =
!isLoading && (Boolean(globalConfigStatus?.exists) || onboardingComplete);
useEffect(() => {
if (shouldLeaveOnboarding) {
router.replace(`/dashboard/${searchSpaceId}/new-chat`);
}
}, [shouldLeaveOnboarding, router, searchSpaceId]);
useGlobalLoadingEffect(isLoading || shouldLeaveOnboarding);
if (isLoading || shouldLeaveOnboarding) return null;
return (
<div className="flex min-h-screen select-none flex-col items-center justify-center bg-main-panel p-4">
<div className="w-full max-w-3xl space-y-6 text-center">
<Logo className="mx-auto h-12 w-12" />
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">Choose a model</h1>
<p className="text-sm text-muted-foreground">
Connect any supported provider, then enable the models you want SurfSense to use.
</p>
</div>
<ModelProviderConnectionsPanel
searchSpaceId={searchSpaceId}
connections={connections}
className="flex flex-col gap-6 text-left"
footerAction={
<Button
className="min-w-[112px]"
disabled={!onboardingComplete || !hasUsableChatModel}
onClick={() => router.push(`/dashboard/${searchSpaceId}/new-chat`)}
>
Start
</Button>
}
showAddProviderHeader={false}
/>
</div>
</div>
);
}

View file

@ -1,10 +0,0 @@
import { redirect } from "next/navigation";
export default async function SearchSpaceDashboardPage({
params,
}: {
params: Promise<{ search_space_id: string }>;
}) {
const { search_space_id } = await params;
redirect(`/dashboard/${search_space_id}/new-chat`);
}

View file

@ -1,145 +0,0 @@
"use client";
import { BookText, Cpu, Earth, Settings, UserKey } from "lucide-react";
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { useTranslations } from "next-intl";
import type React from "react";
import { useCallback, useMemo, useState } from "react";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
export type SearchSpaceSettingsTab =
| "general"
| "models"
| "team-roles"
| "prompts"
| "public-links";
const DEFAULT_TAB: SearchSpaceSettingsTab = "general";
interface SearchSpaceSettingsLayoutShellProps {
searchSpaceId: string;
children: React.ReactNode;
}
export function SearchSpaceSettingsLayoutShell({
searchSpaceId,
children,
}: SearchSpaceSettingsLayoutShellProps) {
const t = useTranslations("searchSpaceSettings");
const segment = useSelectedLayoutSegment();
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
const handleTabScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
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(
() => [
{
value: "general" as const,
label: t("nav_general"),
icon: <Settings className="h-4 w-4" />,
},
{
value: "models" as const,
label: t("nav_models"),
icon: <Cpu className="h-4 w-4" />,
},
{
value: "team-roles" as const,
label: t("nav_team_roles"),
icon: <UserKey className="h-4 w-4" />,
},
{
value: "prompts" as const,
label: t("nav_system_instructions"),
icon: <BookText className="h-4 w-4" />,
},
{
value: "public-links" as const,
label: t("nav_public_links"),
icon: <Earth className="h-4 w-4" />,
},
],
[t]
);
const activeTab: SearchSpaceSettingsTab =
segment && navItems.some((item) => item.value === segment)
? (segment as SearchSpaceSettingsTab)
: DEFAULT_TAB;
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
const hrefFor = (tab: SearchSpaceSettingsTab) =>
`/dashboard/${searchSpaceId}/search-space-settings/${tab}`;
return (
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:pt-6 md:flex-row">
<div className="md:w-[220px] md:shrink-0">
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
<nav className="hidden flex-col gap-0.5 md:flex">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</nav>
<div
className="overflow-x-auto border-b border-border pb-3 md:hidden"
onScroll={handleTabScroll}
style={{
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
}}
>
<div className="flex gap-1">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</div>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="hidden md:block">
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
<Separator className="mt-4" />
</div>
<div className="min-w-0 pt-4 md:max-w-3xl">{children}</div>
</div>
</section>
);
}

View file

@ -1,19 +0,0 @@
import type React from "react";
import { use } from "react";
import { SearchSpaceSettingsLayoutShell } from "./layout-shell";
export default function SearchSpaceSettingsLayout({
params,
children,
}: {
params: Promise<{ search_space_id: string }>;
children: React.ReactNode;
}) {
const { search_space_id } = use(params);
return (
<SearchSpaceSettingsLayoutShell searchSpaceId={search_space_id}>
{children}
</SearchSpaceSettingsLayoutShell>
);
}

View file

@ -1,10 +0,0 @@
import { redirect } from "next/navigation";
export default async function SearchSpaceSettingsPage({
params,
}: {
params: Promise<{ search_space_id: string }>;
}) {
const { search_space_id } = await params;
redirect(`/dashboard/${search_space_id}/search-space-settings/general`);
}

View file

@ -1,6 +0,0 @@
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
const { search_space_id } = await params;
return <PromptConfigManager searchSpaceId={Number(search_space_id)} />;
}

View file

@ -1,6 +0,0 @@
import { RolesManager } from "@/components/settings/roles-manager";
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
const { search_space_id } = await params;
return <RolesManager searchSpaceId={Number(search_space_id)} />;
}

View file

@ -1,15 +0,0 @@
import { TeamContent } from "./team-content";
export default async function TeamPage({
params,
}: {
params: Promise<{ search_space_id: string }>;
}) {
const { search_space_id } = await params;
return (
<div className="w-full select-none space-y-6">
<TeamContent searchSpaceId={Number(search_space_id)} />
</div>
);
}

View file

@ -1,5 +0,0 @@
import { AgentStatusContent } from "../components/AgentStatusContent";
export default function Page() {
return <AgentStatusContent />;
}

View file

@ -1,321 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertTriangle, CircleCheck, CircleSlash, Info } from "lucide-react";
import { Fragment, useMemo } from "react";
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import type { AgentFeatureFlags } from "@/lib/apis/agent-flags-api.service";
import { cn } from "@/lib/utils";
type FlagKey = keyof AgentFeatureFlags;
interface FlagDef {
key: FlagKey;
label: string;
description: string;
envVar: string;
}
interface FlagGroup {
id: string;
title: string;
subtitle: string;
flags: FlagDef[];
}
const FLAG_GROUPS: FlagGroup[] = [
{
id: "tier1",
title: "Tier 1 — Agent quality",
subtitle: "Context editing, retries, fallbacks, doom-loop, tool-call repair.",
flags: [
{
key: "enable_context_editing",
label: "Context editing",
description: "Trim tool outputs and spill old text into backend storage.",
envVar: "SURFSENSE_ENABLE_CONTEXT_EDITING",
},
{
key: "enable_compaction_v2",
label: "Compaction v2",
description: "SurfSense-aware compaction replacing safe summarization.",
envVar: "SURFSENSE_ENABLE_COMPACTION_V2",
},
{
key: "enable_retry_after",
label: "Retry-After",
description: "Honour rate-limit retry-after headers automatically.",
envVar: "SURFSENSE_ENABLE_RETRY_AFTER",
},
{
key: "enable_model_fallback",
label: "Model fallback",
description: "Fail over to a backup model on persistent errors.",
envVar: "SURFSENSE_ENABLE_MODEL_FALLBACK",
},
{
key: "enable_model_call_limit",
label: "Model call limit",
description: "Cap total model calls per turn to prevent budget run-aways.",
envVar: "SURFSENSE_ENABLE_MODEL_CALL_LIMIT",
},
{
key: "enable_tool_call_limit",
label: "Tool call limit",
description: "Cap total tool calls per turn.",
envVar: "SURFSENSE_ENABLE_TOOL_CALL_LIMIT",
},
{
key: "enable_tool_call_repair",
label: "Tool-call name repair",
description: "Recover from lower-cased / fuzzy tool names emitted by smaller models.",
envVar: "SURFSENSE_ENABLE_TOOL_CALL_REPAIR",
},
{
key: "enable_doom_loop",
label: "Doom-loop detection",
description: "Detect repeated identical tool calls and ask the user to confirm.",
envVar: "SURFSENSE_ENABLE_DOOM_LOOP",
},
],
},
{
id: "tier2",
title: "Tier 2 — Safety",
subtitle: "Permission rules, busy-mutex, smarter tool selection.",
flags: [
{
key: "enable_permission",
label: "Permission middleware",
description: "Apply allow/deny/ask rules from the Agent Permissions tab.",
envVar: "SURFSENSE_ENABLE_PERMISSION",
},
{
key: "enable_busy_mutex",
label: "Busy mutex",
description: "Prevent two concurrent runs from corrupting the same thread.",
envVar: "SURFSENSE_ENABLE_BUSY_MUTEX",
},
{
key: "enable_llm_tool_selector",
label: "LLM tool selector",
description: "Use a smaller model to pre-filter the tool list per turn.",
envVar: "SURFSENSE_ENABLE_LLM_TOOL_SELECTOR",
},
],
},
{
id: "tier4",
title: "Tier 4 — Skills + subagents",
subtitle: "Built-in skills, specialized subagents, KB planner runnable.",
flags: [
{
key: "enable_skills",
label: "Skills",
description: "Load on-demand skill packs (kb-research, report-writing, …).",
envVar: "SURFSENSE_ENABLE_SKILLS",
},
{
key: "enable_specialized_subagents",
label: "Specialized subagents",
description: "Spin up explore / report_writer / connector_negotiator subagents.",
envVar: "SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS",
},
],
},
{
id: "tier5",
title: "Tier 5 — Audit + revert",
subtitle: "Action log + revert route used by the Agent Actions dialog.",
flags: [
{
key: "enable_action_log",
label: "Action log",
description: "Persist every tool call to agent_action_log.",
envVar: "SURFSENSE_ENABLE_ACTION_LOG",
},
{
key: "enable_revert_route",
label: "Revert route",
description: "Allow reverting reversible actions from the action log.",
envVar: "SURFSENSE_ENABLE_REVERT_ROUTE",
},
],
},
{
id: "tier6",
title: "Tier 6 — Plugins",
subtitle: "Optional middleware loaded from entry points.",
flags: [
{
key: "enable_plugin_loader",
label: "Plugin loader",
description: "Load surfsense.plugins entry-point middleware.",
envVar: "SURFSENSE_ENABLE_PLUGIN_LOADER",
},
],
},
{
id: "obs",
title: "Observability",
subtitle: "Telemetry pipelines (orthogonal to feature gating).",
flags: [
{
key: "enable_otel",
label: "OpenTelemetry",
description: "Emit OTel spans (also requires OTEL_EXPORTER_OTLP_ENDPOINT).",
envVar: "SURFSENSE_ENABLE_OTEL",
},
],
},
{
id: "desktop",
title: "Desktop",
subtitle: "Desktop-only capabilities exposed by the backend deployment.",
flags: [
{
key: "enable_desktop_local_filesystem",
label: "Local filesystem",
description: "Allow Desktop chat sessions to operate directly on selected local folders.",
envVar: "ENABLE_DESKTOP_LOCAL_FILESYSTEM",
},
],
},
];
function FlagRow({ def, value }: { def: FlagDef; value: boolean }) {
return (
<div className="flex items-start justify-between gap-4 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{def.label}</span>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{def.envVar}
</code>
</div>
<p className="text-xs text-muted-foreground">{def.description}</p>
</div>
<Badge
variant={value ? "default" : "secondary"}
className={cn(
"shrink-0 gap-1",
value
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600"
: "text-muted-foreground"
)}
>
{value ? <CircleCheck className="size-3" /> : <CircleSlash className="size-3" />}
{value ? "On" : "Off"}
</Badge>
</div>
);
}
export function AgentStatusContent() {
const { data: flags, isLoading, isError, error } = useAtomValue(agentFlagsAtom);
const enabledCount = useMemo(() => {
if (!flags) return 0;
return Object.entries(flags).filter(([k, v]) => k !== "disable_new_agent_stack" && v === true)
.length;
}, [flags]);
if (isLoading) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-12 w-full rounded-md" />
<Skeleton className="h-32 w-full rounded-md" />
<Skeleton className="h-32 w-full rounded-md" />
</div>
);
}
if (isError || !flags) {
return (
<Alert variant="destructive">
<AlertTriangle />
<AlertTitle>Failed to load agent status</AlertTitle>
<AlertDescription>
{error instanceof Error ? error.message : "Unknown error."}
</AlertDescription>
</Alert>
);
}
const masterOff = flags.disable_new_agent_stack;
return (
<div className="space-y-6">
{masterOff ? (
<Alert variant="destructive">
<AlertTriangle />
<AlertTitle>Master kill-switch is on</AlertTitle>
<AlertDescription>
<p>
Showing that{" "}
<code className="rounded bg-muted px-1 text-[10px]">
SURFSENSE_DISABLE_NEW_AGENT_STACK=true
</code>
, which forces every new middleware off, regardless of the individual flags below.
Restart the backend after changing it.
</p>
</AlertDescription>
</Alert>
) : (
<Alert>
<Info />
<AlertTitle className="flex items-center gap-2">
Agent stack
<Badge
variant="secondary"
className="rounded bg-popover px-1 py-0.5 text-[9px] text-popover-foreground"
>
{enabledCount} on
</Badge>
</AlertTitle>
<AlertDescription>
<p>
Showing a read-only mirror of the backend's <code>AgentFeatureFlags</code>. Flip an
env var and restart the backend to change a value.
</p>
</AlertDescription>
</Alert>
)}
{FLAG_GROUPS.map((group, groupIdx) => {
const allOff = group.flags.every((f) => !flags[f.key]);
return (
<div key={group.id}>
{groupIdx > 0 && <Separator className="my-4 bg-border" />}
<div className="rounded-lg border border-border/60 bg-card">
<div className="flex items-start justify-between gap-3 px-4 py-3">
<div>
<p className="text-sm font-semibold">{group.title}</p>
<p className="text-xs text-muted-foreground">{group.subtitle}</p>
</div>
{allOff && (
<Badge variant="outline" className="text-[10px] text-muted-foreground">
all off
</Badge>
)}
</div>
<Separator className="bg-border" />
<div className="px-4">
{group.flags.map((def, flagIdx) => (
<Fragment key={def.key}>
{flagIdx > 0 && <Separator className="bg-border" />}
<FlagRow def={def} value={flags[def.key]} />
</Fragment>
))}
</div>
</div>
</div>
);
})}
</div>
);
}

View file

@ -1,188 +0,0 @@
"use client";
import {
CircleUser,
Keyboard,
KeyRound,
Library,
MessageCircle,
Monitor,
ReceiptText,
ShieldCheck,
WandSparkles,
Workflow,
} from "lucide-react";
import Link from "next/link";
import { useSelectedLayoutSegment } from "next/navigation";
import { useTranslations } from "next-intl";
import type React from "react";
import { useCallback, useMemo, useState } from "react";
import { Separator } from "@/components/ui/separator";
import { usePlatform } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
export type UserSettingsTab =
| "profile"
| "api-key"
| "prompts"
| "community-prompts"
| "agent-permissions"
| "agent-status"
| "purchases"
| "desktop"
| "hotkeys"
| "messaging-channels";
const DEFAULT_TAB: UserSettingsTab = "profile";
interface UserSettingsLayoutShellProps {
searchSpaceId: string;
children: React.ReactNode;
}
export function UserSettingsLayoutShell({ searchSpaceId, children }: UserSettingsLayoutShellProps) {
const t = useTranslations("userSettings");
const { isDesktop } = usePlatform();
const segment = useSelectedLayoutSegment();
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
const handleTabScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
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(
() => [
{
value: "profile" as const,
label: t("profile_nav_label"),
icon: <CircleUser className="h-4 w-4" />,
},
{
value: "api-key" as const,
label: t("api_key_nav_label"),
icon: <KeyRound className="h-4 w-4" />,
},
{
value: "prompts" as const,
label: "My Prompts",
icon: <WandSparkles className="h-4 w-4" />,
},
{
value: "community-prompts" as const,
label: "Community Prompts",
icon: <Library className="h-4 w-4" />,
},
{
value: "agent-permissions" as const,
label: "Agent Permissions",
icon: <ShieldCheck className="h-4 w-4" />,
},
{
value: "agent-status" as const,
label: "Agent Status",
icon: <Workflow className="h-4 w-4" />,
},
{
value: "messaging-channels" as const,
label: "Messaging Channels",
icon: <MessageCircle className="h-4 w-4" />,
},
{
value: "purchases" as const,
label: "Purchase History",
icon: <ReceiptText className="h-4 w-4" />,
},
...(isDesktop
? [
{
value: "desktop" as const,
label: "App Preferences",
icon: <Monitor className="h-4 w-4" />,
},
{
value: "hotkeys" as const,
label: "Hotkeys",
icon: <Keyboard className="h-4 w-4" />,
},
]
: []),
],
[t, isDesktop]
);
const activeTab: UserSettingsTab =
segment && navItems.some((item) => item.value === segment)
? (segment as UserSettingsTab)
: DEFAULT_TAB;
const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
const hrefFor = (tab: UserSettingsTab) => `/dashboard/${searchSpaceId}/user-settings/${tab}`;
return (
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:pt-10 md:flex-row">
<div className="md:w-[220px] md:shrink-0">
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
<nav className="hidden flex-col gap-0.5 md:flex">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</nav>
<div
className="overflow-x-auto border-b border-border pb-3 md:hidden"
onScroll={handleTabScroll}
style={{
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
}}
>
<div className="flex gap-1">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</div>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="hidden md:block">
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
<Separator className="mt-4 bg-border" />
</div>
<div className="min-w-0 pt-4 md:max-w-3xl">{children}</div>
</div>
</section>
);
}

View file

@ -1,10 +0,0 @@
import { redirect } from "next/navigation";
export default async function UserSettingsPage({
params,
}: {
params: Promise<{ search_space_id: string }>;
}) {
const { search_space_id } = await params;
redirect(`/dashboard/${search_space_id}/user-settings/profile`);
}

View file

@ -5,7 +5,7 @@ import { ArtifactsLibrary } from "@/features/artifacts-library";
export default function ArtifactsPage() {
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
const workspaceId = Number(params.workspace_id);
return <ArtifactsLibrary searchSpaceId={searchSpaceId} />;
return <ArtifactsLibrary workspaceId={workspaceId} />;
}

View file

@ -10,7 +10,7 @@ import { AutomationRunsSection } from "./components/automation-runs-section";
import { AutomationTriggersSection } from "./components/automation-triggers-section";
interface AutomationDetailContentProps {
searchSpaceId: number;
workspaceId: number;
automationId: number;
}
@ -28,7 +28,7 @@ interface AutomationDetailContentProps {
* so the orchestrator stays thin.
*/
export function AutomationDetailContent({
searchSpaceId,
workspaceId,
automationId,
}: AutomationDetailContentProps) {
const perms = useAutomationPermissions();
@ -45,14 +45,14 @@ export function AutomationDetailContent({
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
You don't have permission to view automations in this search space.
You don't have permission to view automations in this workspace.
</p>
</div>
);
}
if (!validId) {
return <AutomationNotFound searchSpaceId={searchSpaceId} />;
return <AutomationNotFound workspaceId={workspaceId} />;
}
if (isLoading) {
@ -60,14 +60,14 @@ export function AutomationDetailContent({
}
if (error || !automation) {
return <AutomationNotFound searchSpaceId={searchSpaceId} error={error} />;
return <AutomationNotFound workspaceId={workspaceId} error={error} />;
}
return (
<>
<AutomationDetailHeader
automation={automation}
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
canUpdate={perms.canUpdate}
canDelete={perms.canDelete}
/>

View file

@ -12,7 +12,7 @@ import { DeleteAutomationDialog } from "../../components/delete-automation-dialo
interface AutomationDetailHeaderProps {
automation: Automation;
searchSpaceId: number;
workspaceId: number;
canUpdate: boolean;
canDelete: boolean;
}
@ -28,7 +28,7 @@ interface AutomationDetailHeaderProps {
*/
export function AutomationDetailHeader({
automation,
searchSpaceId,
workspaceId,
canUpdate,
canDelete,
}: AutomationDetailHeaderProps) {
@ -44,8 +44,8 @@ export function AutomationDetailHeader({
const PauseIcon = automation.status === "active" ? Pause : Play;
const handleDeleted = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/automations`);
}, [router, searchSpaceId]);
router.push(`/dashboard/${workspaceId}/automations`);
}, [router, workspaceId]);
async function handleTogglePause() {
await updateAutomation({
@ -59,7 +59,7 @@ export function AutomationDetailHeader({
<div className="space-y-3">
<Button asChild variant="ghost" size="sm" className="-ml-2 h-auto px-2 py-1">
<Link
href={`/dashboard/${searchSpaceId}/automations`}
href={`/dashboard/${workspaceId}/automations`}
className="text-xs text-muted-foreground"
>
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
@ -86,7 +86,7 @@ export function AutomationDetailHeader({
size="sm"
className="justify-start rounded-md bg-muted px-3 hover:bg-accent"
>
<Link href={`/dashboard/${searchSpaceId}/automations/${automation.id}/edit`}>
<Link href={`/dashboard/${workspaceId}/automations/${automation.id}/edit`}>
<Pencil className="mr-1 h-4 w-4" />
Edit
</Link>
@ -141,7 +141,7 @@ export function AutomationDetailHeader({
onOpenChange={setDeleteOpen}
automationId={automation.id}
automationName={automation.name}
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
onDeleted={handleDeleted}
/>
)}

View file

@ -4,7 +4,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
interface AutomationNotFoundProps {
searchSpaceId: number;
workspaceId: number;
error?: Error | null;
}
@ -14,7 +14,7 @@ interface AutomationNotFoundProps {
* UI on purpose leaking that an id exists you can't read is worse than
* a vague message.
*/
export function AutomationNotFound({ searchSpaceId, error }: AutomationNotFoundProps) {
export function AutomationNotFound({ workspaceId, error }: AutomationNotFoundProps) {
return (
<div className="rounded-lg border border-border/60 bg-muted/20 px-6 py-12 text-center">
<FileWarning className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
@ -24,7 +24,7 @@ export function AutomationNotFound({ searchSpaceId, error }: AutomationNotFoundP
{error?.message ? ` (${error.message})` : null}
</p>
<Button asChild variant="outline" size="sm" className="mt-6">
<Link href={`/dashboard/${searchSpaceId}/automations`}>
<Link href={`/dashboard/${workspaceId}/automations`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to automations
</Link>

View file

@ -8,7 +8,7 @@ import { AutomationNotFound } from "../components/automation-not-found";
import { AutomationEditHeader } from "./components/automation-edit-header";
interface AutomationEditContentProps {
searchSpaceId: number;
workspaceId: number;
automationId: number;
}
@ -17,7 +17,7 @@ interface AutomationEditContentProps {
* structure but gates on ``canUpdate`` instead of ``canRead``: a user who
* can read but not update is bounced to the access-denied panel.
*/
export function AutomationEditContent({ searchSpaceId, automationId }: AutomationEditContentProps) {
export function AutomationEditContent({ workspaceId, automationId }: AutomationEditContentProps) {
const perms = useAutomationPermissions();
const validId = Number.isInteger(automationId) && automationId > 0;
const { data: automation, isLoading, error } = useAutomation(validId ? automationId : undefined);
@ -32,14 +32,14 @@ export function AutomationEditContent({ searchSpaceId, automationId }: Automatio
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
You don't have permission to edit automations in this search space.
You don't have permission to edit automations in this workspace.
</p>
</div>
);
}
if (!validId) {
return <AutomationNotFound searchSpaceId={searchSpaceId} />;
return <AutomationNotFound workspaceId={workspaceId} />;
}
if (isLoading) {
@ -47,18 +47,18 @@ export function AutomationEditContent({ searchSpaceId, automationId }: Automatio
}
if (error || !automation) {
return <AutomationNotFound searchSpaceId={searchSpaceId} error={error} />;
return <AutomationNotFound workspaceId={workspaceId} error={error} />;
}
return (
<AutomationBuilderForm
mode="edit"
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
automation={automation}
renderModeSwitcher={(modeSwitcher) => (
<AutomationEditHeader
automation={automation}
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
modeSwitcher={modeSwitcher}
/>
)}

View file

@ -7,16 +7,16 @@ import type { Automation } from "@/contracts/types/automation.types";
interface AutomationEditHeaderProps {
automation: Automation;
searchSpaceId: number;
workspaceId: number;
modeSwitcher?: ReactNode;
}
export function AutomationEditHeader({
automation,
searchSpaceId,
workspaceId,
modeSwitcher,
}: AutomationEditHeaderProps) {
const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`;
const detailHref = `/dashboard/${workspaceId}/automations/${automation.id}`;
return (
<div className="space-y-3">

View file

@ -3,14 +3,14 @@ import { AutomationEditContent } from "./automation-edit-content";
export default async function AutomationEditPage({
params,
}: {
params: Promise<{ search_space_id: string; automation_id: string }>;
params: Promise<{ workspace_id: string; automation_id: string }>;
}) {
const { search_space_id, automation_id } = await params;
const { workspace_id, automation_id } = await params;
return (
<div className="w-full space-y-6">
<AutomationEditContent
searchSpaceId={Number(search_space_id)}
workspaceId={Number(workspace_id)}
automationId={Number(automation_id)}
/>
</div>

View file

@ -3,14 +3,14 @@ import { AutomationDetailContent } from "./automation-detail-content";
export default async function AutomationDetailPage({
params,
}: {
params: Promise<{ search_space_id: string; automation_id: string }>;
params: Promise<{ workspace_id: string; automation_id: string }>;
}) {
const { search_space_id, automation_id } = await params;
const { workspace_id, automation_id } = await params;
return (
<div className="w-full space-y-6">
<AutomationDetailContent
searchSpaceId={Number(search_space_id)}
workspaceId={Number(workspace_id)}
automationId={Number(automation_id)}
/>
</div>

View file

@ -8,19 +8,19 @@ import { AutomationsTable } from "./components/automations-table";
import { useAutomationPermissions } from "./hooks/use-automation-permissions";
interface AutomationsContentProps {
searchSpaceId: number;
workspaceId: number;
}
/**
* Client orchestrator for the automations list page. Pulls the active
* search space's first page (via ``useAutomations`` ``automationsListAtom``)
* workspace's first page (via ``useAutomations`` ``automationsListAtom``)
* and the user's permissions, then decides between empty / loading / table.
*
* Read access is mandatory; anything else is hidden behind RBAC. The
* permissions hook is co-located in this slice so adding/removing
* surfaces is a one-file change.
*/
export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
export function AutomationsContent({ workspaceId }: AutomationsContentProps) {
const { automations, total, loading, error } = useAutomations();
const perms = useAutomationPermissions();
@ -28,10 +28,10 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
// Permissions gate the entire page; defer everything until we know.
return (
<>
<AutomationsHeader searchSpaceId={searchSpaceId} total={0} loading canCreate={false} />
<AutomationsHeader workspaceId={workspaceId} total={0} loading canCreate={false} />
<AutomationsTable
automations={[]}
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
loading
canUpdate={false}
canDelete={false}
@ -46,7 +46,7 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
You don't have permission to view automations in this search space.
You don't have permission to view automations in this workspace.
</p>
</div>
);
@ -56,7 +56,7 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
return (
<>
<AutomationsHeader
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
total={0}
loading={false}
canCreate={perms.canCreate}
@ -73,13 +73,13 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
return (
<>
<AutomationsHeader
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
total={0}
loading={false}
canCreate={perms.canCreate}
showCreateCta={false}
/>
<AutomationsEmptyState searchSpaceId={searchSpaceId} canCreate={perms.canCreate} />
<AutomationsEmptyState workspaceId={workspaceId} canCreate={perms.canCreate} />
</>
);
}
@ -87,14 +87,14 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
return (
<>
<AutomationsHeader
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
total={total}
loading={loading}
canCreate={perms.canCreate}
/>
<AutomationsTable
automations={automations}
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
loading={loading}
canUpdate={perms.canUpdate}
canDelete={perms.canDelete}

View file

@ -15,7 +15,7 @@ import { DeleteAutomationDialog } from "./delete-automation-dialog";
interface AutomationRowActionsProps {
automation: AutomationSummary;
searchSpaceId: number;
workspaceId: number;
canUpdate: boolean;
canDelete: boolean;
}
@ -27,7 +27,7 @@ interface AutomationRowActionsProps {
*/
export function AutomationRowActions({
automation,
searchSpaceId,
workspaceId,
canUpdate,
canDelete,
}: AutomationRowActionsProps) {
@ -85,7 +85,7 @@ export function AutomationRowActions({
onOpenChange={setDeleteOpen}
automationId={automation.id}
automationName={automation.name}
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
/>
)}
</>

View file

@ -8,7 +8,7 @@ import { AutomationStatusBadge } from "./automation-status-badge";
interface AutomationRowProps {
automation: AutomationSummary;
searchSpaceId: number;
workspaceId: number;
canUpdate: boolean;
canDelete: boolean;
}
@ -21,7 +21,7 @@ interface AutomationRowProps {
*/
export function AutomationRow({
automation,
searchSpaceId,
workspaceId,
canUpdate,
canDelete,
}: AutomationRowProps) {
@ -29,7 +29,7 @@ export function AutomationRow({
<TableRow className="h-12 border-b border-border/60 hover:bg-muted/40">
<TableCell className="px-4 md:px-6 py-2.5 border-r border-border/60 align-middle">
<Link
href={`/dashboard/${searchSpaceId}/automations/${automation.id}`}
href={`/dashboard/${workspaceId}/automations/${automation.id}`}
className="block truncate text-sm font-medium text-foreground hover:underline"
>
{automation.name}
@ -45,7 +45,7 @@ export function AutomationRow({
<div className="flex justify-end">
<AutomationRowActions
automation={automation}
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
canUpdate={canUpdate}
canDelete={canDelete}
/>

View file

@ -4,7 +4,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
interface AutomationsEmptyStateProps {
searchSpaceId: number;
workspaceId: number;
canCreate: boolean;
}
@ -14,7 +14,7 @@ interface AutomationsEmptyStateProps {
* "new automation" form. We surface the chat path explicitly so users
* don't go hunting for an "add" button that doesn't exist.
*/
export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsEmptyStateProps) {
export function AutomationsEmptyState({ workspaceId, canCreate }: AutomationsEmptyStateProps) {
return (
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-12 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
@ -28,19 +28,19 @@ export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsE
{canCreate ? (
<div className="mt-6 flex items-center justify-center gap-2 flex-wrap">
<Button asChild>
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>Create via chat</Link>
<Link href={`/dashboard/${workspaceId}/new-chat`}>Create via chat</Link>
</Button>
<Button
asChild
variant="ghost"
className="h-10 justify-start rounded-md bg-muted px-3 text-sm hover:bg-accent"
>
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>Create manually</Link>
<Link href={`/dashboard/${workspaceId}/automations/new`}>Create manually</Link>
</Button>
</div>
) : (
<p className="mt-6 text-xs text-muted-foreground">
You don't have permission to create automations in this search space.
You don't have permission to create automations in this workspace.
</p>
)}
</div>

View file

@ -3,7 +3,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
interface AutomationsHeaderProps {
searchSpaceId: number;
workspaceId: number;
total: number;
loading: boolean;
canCreate: boolean;
@ -22,7 +22,7 @@ interface AutomationsHeaderProps {
* handled per-automation in the builder + approval card, not gated here.
*/
export function AutomationsHeader({
searchSpaceId,
workspaceId,
total,
loading,
canCreate,
@ -46,10 +46,10 @@ export function AutomationsHeader({
variant="ghost"
className="justify-start rounded-md bg-muted px-3 hover:bg-accent"
>
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>Create manually</Link>
<Link href={`/dashboard/${workspaceId}/automations/new`}>Create manually</Link>
</Button>
<Button asChild size="sm">
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>Create via chat</Link>
<Link href={`/dashboard/${workspaceId}/new-chat`}>Create via chat</Link>
</Button>
</div>
)}

View file

@ -13,21 +13,18 @@ export function AutomationsLoadingRows() {
return (
<>
{ROW_KEYS.map((key) => (
<TableRow key={key} className="border-b border-border/60 hover:bg-transparent">
<TableCell className="px-4 md:px-6 py-3 border-r border-border/60">
<div className="flex flex-col gap-1.5">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
</div>
<TableRow key={key} className="h-12 border-b border-border/60 hover:bg-transparent">
<TableCell className="px-4 md:px-6 py-2.5 border-r border-border/60 align-middle">
<Skeleton className="h-4 w-32 max-w-full md:w-40" />
</TableCell>
<TableCell className="px-4 py-3 border-r border-border/60 w-32">
<TableCell className="px-4 py-2.5 border-r border-border/60 w-32 align-middle">
<Skeleton className="h-5 w-16 rounded-md" />
</TableCell>
<TableCell className="hidden md:table-cell px-4 py-3 border-r border-border/60 w-40">
<TableCell className="hidden md:table-cell px-4 py-2.5 border-r border-border/60 w-40 align-middle">
<Skeleton className="h-3 w-20" />
</TableCell>
<TableCell className="px-4 md:px-6 py-3 w-16">
<Skeleton className="h-8 w-8 rounded-md ml-auto" />
<TableCell className="px-4 md:px-6 py-2.5 w-16 align-middle">
<Skeleton className="h-7 w-7 rounded-md ml-auto" />
</TableCell>
</TableRow>
))}

View file

@ -7,7 +7,7 @@ import { AutomationsLoadingRows } from "./automations-loading";
interface AutomationsTableProps {
automations: AutomationSummary[];
searchSpaceId: number;
workspaceId: number;
loading: boolean;
canUpdate: boolean;
canDelete: boolean;
@ -19,7 +19,7 @@ interface AutomationsTableProps {
*/
export function AutomationsTable({
automations,
searchSpaceId,
workspaceId,
loading,
canUpdate,
canDelete,
@ -60,7 +60,7 @@ export function AutomationsTable({
<AutomationRow
key={automation.id}
automation={automation}
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
canUpdate={canUpdate}
canDelete={canDelete}
/>

View file

@ -49,7 +49,7 @@ import { UnattendedToggle } from "./unattended-toggle";
interface AutomationBuilderFormProps {
mode: "create" | "edit";
searchSpaceId: number;
workspaceId: number;
/** Required in edit mode; seeds the form and trigger reconciliation. */
automation?: Automation;
/**
@ -78,7 +78,7 @@ function mapFormErrors(error: z.ZodError): Record<string, string> {
export function AutomationBuilderForm({
mode,
searchSpaceId,
workspaceId,
automation,
submitDisabledReason,
renderModeSwitcher,
@ -120,7 +120,7 @@ export function AutomationBuilderForm({
const [submitting, setSubmitting] = useState(false);
// Eligible models + the search-space-seeded defaults. Models are chosen per
// Eligible models + the workspace-seeded defaults. Models are chosen per
// automation on create; in edit mode the backend preserves the captured
// snapshot, so the picker is create-only.
const eligibleModels = useAutomationEligibleModels();
@ -156,10 +156,7 @@ export function AutomationBuilderForm({
if (mode === "edit" && automation) {
return { ...buildUpdatePayload(formForPayload), status: automation.status };
}
const { search_space_id: _ignored, ...rest } = buildCreatePayload(
formForPayload,
searchSpaceId
);
const { workspace_id: _ignored, ...rest } = buildCreatePayload(formForPayload, workspaceId);
return rest;
}
@ -265,16 +262,16 @@ export function AutomationBuilderForm({
}
await updateAutomation({ automationId: automation.id, patch: parsed.data });
await reconcileTriggers(automation.id);
router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`);
router.push(`/dashboard/${workspaceId}/automations/${automation.id}`);
} else {
const payload = buildCreatePayload(formForPayload, searchSpaceId);
const payload = buildCreatePayload(formForPayload, workspaceId);
const parsed = automationCreateRequest.safeParse(payload);
if (!parsed.success) {
setRootError(zodIssueList(parsed.error).join("; "));
return;
}
const created = await createAutomation(parsed.data);
router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`);
router.push(`/dashboard/${workspaceId}/automations/${created.id}`);
}
} catch (err) {
setRootError((err as Error).message ?? "Submit failed");
@ -294,18 +291,18 @@ export function AutomationBuilderForm({
return;
}
await updateAutomation({ automationId: automation.id, patch: parsed.data });
router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`);
router.push(`/dashboard/${workspaceId}/automations/${automation.id}`);
} else {
const parsed = automationCreateRequest.safeParse({
...jsonValue,
search_space_id: searchSpaceId,
workspace_id: workspaceId,
});
if (!parsed.success) {
setJsonIssues(zodIssueList(parsed.error));
return;
}
const created = await createAutomation(parsed.data);
router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`);
router.push(`/dashboard/${workspaceId}/automations/${created.id}`);
}
} catch (err) {
setJsonIssues([(err as Error).message ?? "Submit failed"]);
@ -396,7 +393,7 @@ export function AutomationBuilderForm({
<TaskList
tasks={form.tasks}
errors={errors}
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
onChange={(tasks) => patchForm({ tasks })}
/>
<UnattendedToggle
@ -427,7 +424,7 @@ export function AutomationBuilderForm({
</CardHeader>
<CardContent>
<AutomationModelFields
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
value={resolvedModels}
onChange={(patch) => patchForm({ models: { ...form.models, ...patch } })}
/>

View file

@ -34,7 +34,7 @@ interface AutomationModelFieldsProps {
/** Resolved (effective) ids — never `0` once defaults are seeded. */
value: AutomationModelSelection;
onChange: (patch: Partial<AutomationModelSelection>) => void;
searchSpaceId: number;
workspaceId: number;
errors?: Partial<Record<keyof AutomationModelSelection, string>>;
}
@ -47,11 +47,11 @@ interface AutomationModelFieldsProps {
export function AutomationModelFields({
value,
onChange,
searchSpaceId,
workspaceId,
errors,
}: AutomationModelFieldsProps) {
const { llm, image, vision, isLoading } = useAutomationEligibleModels();
const rolesHref = `/dashboard/${searchSpaceId}/search-space-settings/models`;
const rolesHref = `/dashboard/${workspaceId}/workspace-settings/models`;
return (
<div className="flex flex-col gap-4">

View file

@ -18,7 +18,7 @@ export function BasicsSection({ name, description, errors, onChange }: BasicsSec
id="automation-name"
value={name}
maxLength={200}
placeholder="Weekly competitor digest"
placeholder="Weekly research digest"
onChange={(e) => onChange({ name: e.target.value })}
/>
</Field>

View file

@ -20,7 +20,7 @@ import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { cn } from "@/lib/utils";
interface MentionTaskInputProps {
searchSpaceId: number;
workspaceId: number;
value: string;
mentions: MentionedDocumentInfo[];
onChange: (text: string, mentions: MentionedDocumentInfo[]) => void;
@ -73,6 +73,9 @@ function toChipInput(mention: MentionedDocumentInfo): MentionChipInput {
if (mention.kind === "folder") {
return { id: mention.id, title: mention.title, kind: "folder" };
}
if (mention.kind === "thread") {
return { id: mention.id, title: mention.title, kind: "thread" };
}
return {
id: mention.id,
title: mention.title,
@ -95,7 +98,7 @@ function removeFirstToken(text: string, token: string): string {
* so the builder can persist IDs for the run.
*/
export function MentionTaskInput({
searchSpaceId,
workspaceId,
value,
mentions,
onChange,
@ -232,7 +235,7 @@ export function MentionTaskInput({
<ComposerSuggestionPopoverContent side="bottom">
<DocumentMentionPicker
ref={pickerRef}
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
onSelectionChange={handleSelection}
onDone={closePopover}
initialSelectedDocuments={mentions}

View file

@ -12,7 +12,7 @@ interface TaskItemProps {
index: number;
total: number;
task: BuilderTask;
searchSpaceId: number;
workspaceId: number;
error?: string;
onChange: (patch: Partial<BuilderTask>) => void;
onMoveUp: () => void;
@ -31,7 +31,7 @@ export function TaskItem({
index,
total,
task,
searchSpaceId,
workspaceId,
error,
onChange,
onMoveUp,
@ -89,7 +89,7 @@ export function TaskItem({
hint="Type @ to reference files, folders, or connectors for extra context."
>
<MentionTaskInput
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
value={task.query}
mentions={task.mentions}
placeholder="What should the agent do? e.g. Summarize new docs in @Marketing since the last run."

View file

@ -7,7 +7,7 @@ import { TaskItem } from "./task-item";
interface TaskListProps {
tasks: BuilderTask[];
errors: Record<string, string>;
searchSpaceId: number;
workspaceId: number;
onChange: (tasks: BuilderTask[]) => void;
}
@ -15,7 +15,7 @@ interface TaskListProps {
* Ordered list of agent tasks. Steps run sequentially in the order shown.
* Reordering is done with up/down buttons to avoid a drag-and-drop dependency.
*/
export function TaskList({ tasks, errors, searchSpaceId, onChange }: TaskListProps) {
export function TaskList({ tasks, errors, workspaceId, onChange }: TaskListProps) {
function updateAt(index: number, patch: Partial<BuilderTask>) {
onChange(tasks.map((task, i) => (i === index ? { ...task, ...patch } : task)));
}
@ -40,7 +40,7 @@ export function TaskList({ tasks, errors, searchSpaceId, onChange }: TaskListPro
index={index}
total={tasks.length}
task={task}
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
error={errors[`tasks.${index}.query`]}
onChange={(patch) => updateAt(index, patch)}
onMoveUp={() => move(index, -1)}

View file

@ -19,7 +19,7 @@ interface DeleteAutomationDialogProps {
onOpenChange: (open: boolean) => void;
automationId: number;
automationName: string;
searchSpaceId: number;
workspaceId: number;
/**
* Fired after a successful delete, before the dialog closes. The detail
* page uses this to navigate back to the list (the row simply vanishes
@ -38,7 +38,7 @@ export function DeleteAutomationDialog({
onOpenChange,
automationId,
automationName,
searchSpaceId,
workspaceId,
onDeleted,
}: DeleteAutomationDialogProps) {
const { mutateAsync: deleteAutomation } = useAtomValue(deleteAutomationMutationAtom);
@ -47,7 +47,7 @@ export function DeleteAutomationDialog({
async function handleConfirm() {
setSubmitting(true);
try {
await deleteAutomation({ automationId, searchSpaceId });
await deleteAutomation({ automationId, workspaceId: workspaceId });
onDeleted?.();
onOpenChange(false);
} finally {

View file

@ -5,7 +5,7 @@ import { useAutomationPermissions } from "../hooks/use-automation-permissions";
import { AutomationNewHeader } from "./components/automation-new-header";
interface AutomationNewContentProps {
searchSpaceId: number;
workspaceId: number;
}
/**
@ -18,7 +18,7 @@ interface AutomationNewContentProps {
* list eligible (premium/BYOK) models, surface a per-slot notice when none
* exist, and block submit until each slot resolves.
*/
export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProps) {
export function AutomationNewContent({ workspaceId }: AutomationNewContentProps) {
const perms = useAutomationPermissions();
if (perms.loading) {
@ -31,7 +31,7 @@ export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProp
<ShieldAlert className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
<h2 className="mt-3 text-base font-semibold text-foreground">Access denied</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
You don't have permission to create automations in this search space.
You don't have permission to create automations in this workspace.
</p>
</div>
);
@ -40,9 +40,9 @@ export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProp
return (
<AutomationBuilderForm
mode="create"
searchSpaceId={searchSpaceId}
workspaceId={workspaceId}
renderModeSwitcher={(modeSwitcher) => (
<AutomationNewHeader searchSpaceId={searchSpaceId} modeSwitcher={modeSwitcher} />
<AutomationNewHeader workspaceId={workspaceId} modeSwitcher={modeSwitcher} />
)}
/>
);

View file

@ -5,17 +5,17 @@ import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
interface AutomationNewHeaderProps {
searchSpaceId: number;
workspaceId: number;
modeSwitcher?: ReactNode;
}
export function AutomationNewHeader({ searchSpaceId, modeSwitcher }: AutomationNewHeaderProps) {
export function AutomationNewHeader({ workspaceId, modeSwitcher }: AutomationNewHeaderProps) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<Button asChild variant="ghost" size="sm" className="-ml-2 h-auto px-2 py-1">
<Link
href={`/dashboard/${searchSpaceId}/automations`}
href={`/dashboard/${workspaceId}/automations`}
className="text-xs text-muted-foreground"
>
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />

View file

@ -3,13 +3,13 @@ import { AutomationNewContent } from "./automation-new-content";
export default async function NewAutomationPage({
params,
}: {
params: Promise<{ search_space_id: string }>;
params: Promise<{ workspace_id: string }>;
}) {
const { search_space_id } = await params;
const { workspace_id } = await params;
return (
<div className="w-full space-y-6">
<AutomationNewContent searchSpaceId={Number(search_space_id)} />
<AutomationNewContent workspaceId={Number(workspace_id)} />
</div>
);
}

View file

@ -3,13 +3,13 @@ import { AutomationsContent } from "./automations-content";
export default async function AutomationsPage({
params,
}: {
params: Promise<{ search_space_id: string }>;
params: Promise<{ workspace_id: string }>;
}) {
const { search_space_id } = await params;
const { workspace_id } = await params;
return (
<div className="mx-auto w-full max-w-5xl space-y-6">
<AutomationsContent searchSpaceId={Number(search_space_id)} />
<AutomationsContent workspaceId={Number(workspace_id)} />
</div>
);
}

View file

@ -6,11 +6,11 @@ import { useEffect } from "react";
export default function BuyPagesPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params?.search_space_id ?? "";
const workspaceId = params?.workspace_id ?? "";
useEffect(() => {
router.replace(`/dashboard/${searchSpaceId}/buy-more`);
}, [router, searchSpaceId]);
router.replace(`/dashboard/${workspaceId}/buy-more`);
}, [router, workspaceId]);
return null;
}

View file

@ -6,11 +6,11 @@ import { useEffect } from "react";
export default function BuyTokensPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params?.search_space_id ?? "";
const workspaceId = params?.workspace_id ?? "";
useEffect(() => {
router.replace(`/dashboard/${searchSpaceId}/buy-more`);
}, [router, searchSpaceId]);
router.replace(`/dashboard/${workspaceId}/buy-more`);
}, [router, workspaceId]);
return null;
}

View file

@ -0,0 +1,11 @@
import { AllChatsWorkspaceContent } from "@/components/layout/ui/sidebar";
export default async function ChatsPage({ params }: { params: Promise<{ workspace_id: string }> }) {
const { workspace_id } = await params;
return (
<div className="w-full select-none">
<AllChatsWorkspaceContent workspaceId={workspace_id} />
</div>
);
}

View file

@ -0,0 +1,174 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import type React from "react";
import { useEffect } from "react";
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms";
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
import { LayoutDataProvider } from "@/components/layout";
import { OnboardingTour } from "@/components/onboarding-tour";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useFolderSync } from "@/hooks/use-folder-sync";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { useElectronAPI } from "@/hooks/use-platform";
export function DashboardClientLayout({
children,
workspaceId,
initialPlaygroundSidebarCollapsed,
}: {
children: React.ReactNode;
workspaceId: string;
initialPlaygroundSidebarCollapsed: boolean;
}) {
const t = useTranslations("dashboard");
const router = useRouter();
const pathname = usePathname();
const { workspace_id } = useParams();
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom);
const setActiveWorkspaceIdState = useSetAtom(activeWorkspaceIdAtom);
const setPendingUserImageUrls = useSetAtom(pendingUserImageDataUrlsAtom);
// Single source of truth for the onboarding gate. Keyed by the route
// workspaceId so the verdict can never lag behind a workspace switch.
const {
data: setupStatus,
isLoading: setupLoading,
error: setupError,
} = useAtomValue(llmSetupStatusAtomFamily(Number(workspaceId)));
const isOnboardingPage = pathname?.includes("/onboard");
const isWorkspaceReady = activeWorkspaceId === workspaceId;
const isReady = setupStatus?.status === "ready";
// First-run (initial_setup) is the only not-ready state that redirects, so
// recovery falls through to the inline composer notice and an established
// user who lost their models is never re-onboarded. The other direction
// leaves onboarding once the workspace can chat.
useEffect(() => {
if (setupLoading || setupError) return;
if (setupStatus?.stage === "initial_setup" && !isOnboardingPage) {
router.replace(`/dashboard/${workspaceId}/onboard`);
} else if (isReady && isOnboardingPage) {
router.replace(`/dashboard/${workspaceId}/new-chat`);
}
}, [
setupLoading,
setupError,
setupStatus?.stage,
isReady,
isOnboardingPage,
router,
workspaceId,
]);
const electronAPI = useElectronAPI();
useEffect(() => {
const htmlBackground = document.documentElement.style.backgroundColor;
const bodyBackground = document.body.style.backgroundColor;
document.documentElement.style.backgroundColor = "var(--panel)";
document.body.style.backgroundColor = "var(--panel)";
return () => {
document.documentElement.style.backgroundColor = htmlBackground;
document.body.style.backgroundColor = bodyBackground;
};
}, []);
useEffect(() => {
if (!electronAPI?.onChatScreenCapture) return;
return electronAPI.onChatScreenCapture((dataUrl: string) => {
if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:image/")) return;
setPendingUserImageUrls((prev) => [...prev, dataUrl]);
});
}, [electronAPI, setPendingUserImageUrls]);
useEffect(() => {
const activeSeacrhSpaceId =
typeof workspace_id === "string"
? workspace_id
: Array.isArray(workspace_id) && workspace_id.length > 0
? workspace_id[0]
: "";
if (!activeSeacrhSpaceId) return;
setActiveWorkspaceIdState(activeSeacrhSpaceId);
// Sync to Electron store if stored value is null (first navigation)
if (electronAPI?.getActiveWorkspace && electronAPI.setActiveWorkspace) {
const setActiveWorkspace = electronAPI.setActiveWorkspace;
electronAPI
.getActiveWorkspace()
.then((stored: string | null) => {
if (!stored) {
setActiveWorkspace(activeSeacrhSpaceId);
}
})
.catch(() => {});
}
}, [workspace_id, setActiveWorkspaceIdState, electronAPI]);
// Suppress children during either pending redirect so neither /new-chat nor
// /onboard flashes for a frame.
const isLeavingOnboarding = isReady && isOnboardingPage;
const isEnteringOnboarding = setupStatus?.stage === "initial_setup" && !isOnboardingPage;
const isRedirecting =
!setupLoading && !setupError && (isLeavingOnboarding || isEnteringOnboarding);
// Block children until the workspace is synced and the initial verdict is
// in; afterwards the composer renders its own not-ready state in place, so
// recovery (e.g. deleting the last model) never triggers a full-screen
// loader or a redirect.
const shouldShowLoading = !setupError && (!isWorkspaceReady || setupLoading || isRedirecting);
useGlobalLoadingEffect(shouldShowLoading);
// Wire desktop app file watcher -> single-file re-index API
useFolderSync();
if (shouldShowLoading) {
return null;
}
if (setupError) {
return (
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium text-destructive">
{t("config_error")}
</CardTitle>
<CardDescription>{t("failed_load_llm_config")}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{setupError instanceof Error ? setupError.message : String(setupError)}
</p>
</CardContent>
</Card>
</div>
);
}
if (isOnboardingPage) {
return <>{children}</>;
}
return (
<DocumentUploadDialogProvider>
<OnboardingTour />
<LayoutDataProvider
workspaceId={workspaceId}
initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed}
>
{children}
</LayoutDataProvider>
</DocumentUploadDialogProvider>
);
}

View file

@ -3,9 +3,9 @@ import { OAUTH_RESULT_COOKIE, type OAuthCallbackResult } from "@/contracts/types
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ search_space_id: string }> }
{ params }: { params: Promise<{ workspace_id: string }> }
) {
const { search_space_id } = await params;
const { workspace_id } = await params;
const searchParams = request.nextUrl.searchParams;
const payload: OAuthCallbackResult = {
@ -19,7 +19,9 @@ export async function GET(
const response = new NextResponse(null, {
status: 302,
headers: {
Location: `/dashboard/${search_space_id}/new-chat`,
// Land on the connectors panel so `useConnectorDialog` (mounted there)
// consumes the result cookie and continues the indexing/edit flow.
Location: `/dashboard/${workspace_id}/connectors`,
},
});
response.cookies.set(OAUTH_RESULT_COOKIE, result, {

View file

@ -0,0 +1,5 @@
import { ConnectorsSection } from "@/components/assistant-ui/connector-popup/connectors-panel";
export default function ConnectorsPage() {
return <ConnectorsSection />;
}

View file

@ -0,0 +1,28 @@
// Server component
import { cookies } from "next/headers";
import type React from "react";
import { DashboardClientLayout } from "./client-layout";
const PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE = "surfsense_playground_sidebar_collapsed";
export default async function DashboardLayout({
params,
children,
}: {
params: Promise<{ workspace_id: string }>;
children: React.ReactNode;
}) {
const [{ workspace_id }, cookieStore] = await Promise.all([params, cookies()]);
const initialPlaygroundSidebarCollapsed =
cookieStore.get(PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE)?.value === "true";
return (
<DashboardClientLayout
workspaceId={workspace_id}
initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed}
>
{children}
</DashboardClientLayout>
);
}

View file

@ -338,7 +338,7 @@ export default function LogsManagePage() {
const t = useTranslations("logs");
const id = useId();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
const workspaceId = Number(params.workspace_id);
const { mutateAsync: deleteLogMutation } = useAtomValue(deleteLogMutationAtom);
const { mutateAsync: updateLogMutation } = useAtomValue(updateLogMutationAtom);
@ -383,13 +383,13 @@ export default function LogsManagePage() {
[deleteLogMutation]
);
const { logs, loading: logsLoading, error: logsError, refreshLogs } = useLogs(searchSpaceId);
const { logs, loading: logsLoading, error: logsError, refreshLogs } = useLogs(workspaceId);
const {
summary,
loading: summaryLoading,
error: summaryError,
refreshSummary,
} = useLogsSummary(searchSpaceId, 24);
} = useLogsSummary(workspaceId, 24);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});

View file

@ -8,11 +8,11 @@ import { useEffect } from "react";
export default function MorePagesPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params?.search_space_id ?? "";
const workspaceId = params?.workspace_id ?? "";
useEffect(() => {
router.replace(`/dashboard/${searchSpaceId}/earn-credits`);
}, [router, searchSpaceId]);
router.replace(`/dashboard/${workspaceId}/earn-credits`);
}, [router, workspaceId]);
return null;
}

View file

@ -0,0 +1,768 @@
"use client";
import {
type AppendMessage,
AssistantRuntimeProvider,
type ThreadMessageLike,
useExternalStoreRuntime,
} from "@assistant-ui/react";
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import dynamic from "next/dynamic";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
clearTargetCommentIdAtom,
currentThreadAtom,
setCurrentThreadMetadataAtom,
setTargetCommentIdAtom,
} from "@/atoms/chat/current-thread.atom";
import {
type MentionedDocumentInfo,
mentionedDocumentsAtom,
messageDocumentsMapAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { clearPlanOwnerRegistry } from "@/atoms/chat/plan-state.atom";
import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { removeChatTabAtom, syncChatTabAtom } from "@/atoms/tabs/tabs.atom";
import {
EditMessageDialog,
type EditMessageDialogChoice,
} from "@/components/assistant-ui/edit-message-dialog";
import { StepSeparatorDataUI } from "@/components/assistant-ui/step-separator";
import { Thread } from "@/components/assistant-ui/thread";
import {
type TokenUsageData,
TokenUsageProvider,
} from "@/components/assistant-ui/token-usage-context";
import { Button } from "@/components/ui/button";
import { useSyncChatArtifacts } from "@/features/chat-artifacts";
import {
type HitlDecision,
PendingInterruptProvider,
type PendingInterruptState,
} from "@/features/chat-messages/hitl";
import { TimelineDataUI } from "@/features/chat-messages/timeline";
import { useAgentActionsQuery } from "@/hooks/use-agent-actions-query";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
import { useMessagesSync } from "@/hooks/use-messages-sync";
import { useThreadDetail, useThreadMessages } from "@/hooks/use-thread-queries";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import {
convertToThreadMessage,
reconcileInterruptedAssistantMessages,
} from "@/lib/chat/message-utils";
import {
cancelActiveTurn,
type EngineContext,
regenerateChat,
resumeChat,
startNewChat,
} from "@/lib/chat/stream-engine/engine";
import { extractMentionedDocuments } from "@/lib/chat/stream-engine/helpers";
import { chatStreamStore } from "@/lib/chat/stream-engine/store";
import { useChatStream } from "@/lib/chat/stream-engine/use-chat-stream";
import type { ThreadRecord } from "@/lib/chat/thread-persistence";
import {
extractUserTurnForNewChatApi,
type NewChatUserImagePayload,
} from "@/lib/chat/user-turn-api-parts";
import { NotFoundError } from "@/lib/error";
const MobileEditorPanel = dynamic(
() =>
import("@/components/editor-panel/editor-panel").then((m) => ({
default: m.MobileEditorPanel,
})),
{ ssr: false }
);
const MobileHitlEditPanel = dynamic(
() =>
import("@/features/chat-messages/hitl").then((m) => ({
default: m.MobileHitlEditPanel,
})),
{ ssr: false }
);
const MobileReportPanel = dynamic(
() =>
import("@/components/report-panel/report-panel").then((m) => ({
default: m.MobileReportPanel,
})),
{ ssr: false }
);
const MobileArtifactsPanel = dynamic(
() =>
import("@/features/chat-artifacts/ui/artifacts-panel").then((m) => ({
default: m.MobileArtifactsPanel,
})),
{ ssr: false }
);
/** Stable empty reference so idle threads don't re-render the interrupt provider. */
const EMPTY_PENDING_INTERRUPTS: PendingInterruptState[] = [];
const EMPTY_MESSAGES: ThreadMessageLike[] = [];
function parseUrlChatId(id: string | string[] | undefined): number {
let parsed = 0;
if (Array.isArray(id) && id.length > 0) {
parsed = Number.parseInt(id[0], 10);
} else if (typeof id === "string") {
parsed = Number.parseInt(id, 10);
}
return Number.isNaN(parsed) ? 0 : parsed;
}
export default function NewChatPage() {
const params = useParams();
const queryClient = useQueryClient();
const urlChatId = useMemo(() => parseUrlChatId(params.chat_id), [params.chat_id]);
const [threadId, setThreadId] = useState<number | null>(() => (urlChatId > 0 ? urlChatId : null));
const activeThreadId = urlChatId > 0 ? urlChatId : threadId;
const handledLoadErrorThreadRef = useRef<number | null>(null);
const [currentThread, setCurrentThread] = useState<ThreadRecord | null>(null);
// DB-hydrated messages for the viewed thread (idle display). While a turn
// is streaming, the live overlay in ``chatStreamStore`` takes precedence
// (see ``displayMessages``) so it survives this page unmounting on nav.
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
// Durable, cross-navigation streaming state for the viewed thread.
const streamState = useChatStream(activeThreadId);
const isRunning = streamState?.isRunning ?? false;
const pendingInterrupts = streamState?.pendingInterrupts ?? EMPTY_PENDING_INTERRUPTS;
// Live overlay while a turn is streaming / awaiting HITL; DB-hydrated
// messages once the overlay is cleared (the hydration effect drops it only
// after the DB catches up, so there is no finish->refetch gap).
const displayMessages = streamState ? streamState.messages : messages;
// One shared token-usage store, alive across navigation.
const tokenUsageStore = chatStreamStore.tokenUsage;
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
const currentThreadState = useAtomValue(currentThreadAtom);
const setCurrentThreadMetadata = useSetAtom(setCurrentThreadMetadataAtom);
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
const closeReportPanel = useSetAtom(closeReportPanelAtom);
const closeEditorPanel = useSetAtom(closeEditorPanelAtom);
const syncChatTab = useSetAtom(syncChatTabAtom);
const removeChatTab = useSetAtom(removeChatTabAtom);
// Edit dialog state. Holds the message id being edited and the (already
// extracted) regenerate args so we can resume the edit after the user picks
// "revert all" / "continue" / "cancel".
const [editDialogState, setEditDialogState] = useState<{
fromMessageId: number;
userQuery: string | null;
userMessageContent: ThreadMessageLike["content"];
userImages: NewChatUserImagePayload[];
downstreamReversibleCount: number;
downstreamTotalCount: number;
} | null>(null);
// Per-card staged decisions held until every pending card has submitted, at
// which point we batch them into one ``hitl-decision`` event in the same
// order as ``pendingInterrupts``. A ref because partial progress should not
// re-render the page.
const stagedDecisionsByInterruptIdRef = useRef<Map<string, HitlDecision[]>>(new Map());
const threadDetailQuery = useThreadDetail(activeThreadId);
const threadMessagesQuery = useThreadMessages(activeThreadId);
const hydratedMessagesRef = useRef<{
threadId: number | null;
data: typeof threadMessagesQuery.data;
}>({ threadId: null, data: undefined });
const hasLiveStream = !!streamState && streamState.messages.length > 0;
const isActiveThreadHydrated = hydratedMessagesRef.current.threadId === activeThreadId;
const shouldHideStaleMessages = !!activeThreadId && !hasLiveStream && !isActiveThreadHydrated;
const isThreadMessagesLoading =
shouldHideStaleMessages && !threadMessagesQuery.error;
const runtimeMessages = shouldHideStaleMessages ? EMPTY_MESSAGES : displayMessages;
// Live collaboration: sync session state and messages via Zero. Kept on the
// page because "AI responding" reflects the currently-viewed thread.
useChatSessionStateSync(activeThreadId);
const { data: membersData } = useAtomValue(membersAtom);
const handleSyncedMessagesUpdate = useCallback(
(
syncedMessages: {
id: number;
thread_id: number;
role: string;
content: unknown;
author_id: string | null;
created_at: string;
turn_id?: string | null;
}[]
) => {
if (isRunning) {
return;
}
setMessages((prev) => {
if (syncedMessages.length < prev.length) {
return prev;
}
const memberById = new Map(membersData?.map((m) => [m.user_id, m]) ?? []);
const prevById = new Map(prev.map((m) => [m.id, m]));
return reconcileInterruptedAssistantMessages(syncedMessages).map((msg) => {
const member = msg.author_id ? (memberById.get(msg.author_id) ?? null) : null;
const existingMsg = prevById.get(`msg-${msg.id}`);
const existingAuthor = existingMsg?.metadata?.custom?.author as
| { displayName?: string | null; avatarUrl?: string | null }
| undefined;
return convertToThreadMessage({
id: msg.id,
thread_id: msg.thread_id,
role: msg.role.toLowerCase() as "user" | "assistant" | "system",
content: msg.content,
author_id: msg.author_id,
created_at: msg.created_at,
author_display_name: member?.user_display_name ?? existingAuthor?.displayName ?? null,
author_avatar_url: member?.user_avatar_url ?? existingAuthor?.avatarUrl ?? null,
turn_id: msg.turn_id ?? null,
});
});
});
},
[isRunning, membersData]
);
useMessagesSync(activeThreadId, handleSyncedMessagesUpdate);
// Extract workspace_id from URL params
const workspaceId = useMemo(() => {
const id = params.workspace_id;
const parsed = typeof id === "string" ? Number.parseInt(id, 10) : 0;
return Number.isNaN(parsed) ? 0 : parsed;
}, [params.workspace_id]);
// Unified store for agent-action rows (react-query cache). Used by the
// edit pre-flight to count reversible downstream actions.
const { items: agentActionItems } = useAgentActionsQuery(activeThreadId);
// Latest displayed messages, read by the engine wrappers at call time so
// history/slice seeds stay fresh without re-creating the callbacks.
const messagesRef = useRef<ThreadMessageLike[]>(runtimeMessages);
messagesRef.current = runtimeMessages;
const buildCtx = useCallback(
(): EngineContext => ({
workspaceId,
threadId: activeThreadId,
priorMessages: messagesRef.current,
view: { setThreadId, setCurrentThread },
}),
[workspaceId, activeThreadId]
);
// Reset thread-local runtime state on route/workspace changes. The durable
// streaming overlay is preserved for any still-running thread (and the newly
// viewed thread) via ``clearInactive`` so an in-flight turn survives nav.
useEffect(() => {
const nextThreadId = urlChatId > 0 ? urlChatId : null;
handledLoadErrorThreadRef.current = null;
hydratedMessagesRef.current = { threadId: null, data: undefined };
setThreadId(nextThreadId);
setMessages([]);
setCurrentThread(null);
setMentionedDocuments([]);
tokenUsageStore.clear();
setMessageDocumentsMap({});
clearPlanOwnerRegistry();
closeReportPanel();
closeEditorPanel();
chatStreamStore.clearInactive(nextThreadId);
}, [
urlChatId,
setMentionedDocuments,
setMessageDocumentsMap,
closeReportPanel,
closeEditorPanel,
]);
useEffect(() => {
if (!activeThreadId) {
setCurrentThread(null);
return;
}
if (threadDetailQuery.data?.id === activeThreadId) {
const thread = threadDetailQuery.data;
setCurrentThread(thread);
syncChatTab({
chatId: thread.id,
workspaceId: thread.workspace_id ?? workspaceId,
});
}
}, [activeThreadId, workspaceId, syncChatTab, threadDetailQuery.data]);
useEffect(() => {
const messagesResponse = threadMessagesQuery.data;
if (!activeThreadId || !messagesResponse) return;
if (
hydratedMessagesRef.current.threadId === activeThreadId &&
hydratedMessagesRef.current.data === messagesResponse
) {
return;
}
// Per-thread gate: never overwrite the live overlay of a running turn.
if (isRunning) {
return;
}
const loadedMessages = reconcileInterruptedAssistantMessages(messagesResponse.messages).map(
convertToThreadMessage
);
if (messages.length > 0 && loadedMessages.length < messages.length) {
return;
}
setMessages(loadedMessages);
tokenUsageStore.clear();
const restoredDocsMap: Record<string, MentionedDocumentInfo[]> = {};
for (const msg of messagesResponse.messages) {
if (msg.token_usage) {
tokenUsageStore.set(`msg-${msg.id}`, msg.token_usage as TokenUsageData);
}
if (msg.role === "user") {
const docs = extractMentionedDocuments(msg.content);
if (docs.length > 0) {
restoredDocsMap[`msg-${msg.id}`] = docs;
}
}
}
setMessageDocumentsMap(restoredDocsMap);
hydratedMessagesRef.current = { threadId: activeThreadId, data: messagesResponse };
// The DB is now authoritative for this thread — drop the streaming
// overlay so we render DB messages (no-op while running / HITL-pending).
if (loadedMessages.length >= chatStreamStore.getMessages(activeThreadId).length) {
chatStreamStore.clear(activeThreadId);
}
}, [
activeThreadId,
isRunning,
messages.length,
setMessageDocumentsMap,
threadMessagesQuery.data,
]);
useEffect(() => {
const loadError = threadDetailQuery.error ?? threadMessagesQuery.error;
if (!activeThreadId || !loadError) return;
if (handledLoadErrorThreadRef.current === activeThreadId) return;
handledLoadErrorThreadRef.current = activeThreadId;
console.error("[NewChatPage] Failed to load thread:", loadError);
if (loadError instanceof NotFoundError) {
removeChatTab(activeThreadId);
if (typeof window !== "undefined") {
window.history.replaceState(null, "", `/dashboard/${workspaceId}/new-chat`);
}
setThreadId(null);
setCurrentThread(null);
setMessages([]);
toast.error("This chat was deleted.");
return;
}
toast.error("Failed to load chat. Please try again.");
}, [
activeThreadId,
removeChatTab,
workspaceId,
threadDetailQuery.error,
threadMessagesQuery.error,
]);
// Prefetch document titles for @ mention picker so data is ready on type.
useEffect(() => {
if (!workspaceId) return;
const prefetchParams = {
workspace_id: workspaceId,
page: 0,
page_size: 20,
};
queryClient.prefetchQuery({
queryKey: ["document-titles", prefetchParams],
queryFn: () => documentsApiService.searchDocumentTitles({ queryParams: prefetchParams }),
staleTime: 60 * 1000,
});
}, [workspaceId, queryClient]);
// Handle scroll to comment from URL query params (e.g., from inbox click).
useEffect(() => {
const readAndApplyCommentId = () => {
const params = new URLSearchParams(window.location.search);
const raw = params.get("commentId");
if (raw && activeThreadId) {
const commentId = Number.parseInt(raw, 10);
if (!Number.isNaN(commentId)) {
setTargetCommentId(commentId);
}
}
};
readAndApplyCommentId();
window.addEventListener("popstate", readAndApplyCommentId);
return () => {
window.removeEventListener("popstate", readAndApplyCommentId);
clearTargetCommentId();
};
}, [activeThreadId, setTargetCommentId, clearTargetCommentId]);
// Sync current thread state to atom
useEffect(() => {
if (!currentThread) {
if (activeThreadId) {
return;
}
setCurrentThreadMetadata({
id: null,
workspaceId: null,
visibility: null,
hasComments: false,
});
return;
}
const visibility =
currentThreadState.id === currentThread.id && currentThreadState.visibility !== null
? currentThreadState.visibility
: currentThread.visibility;
setCurrentThreadMetadata({
id: currentThread.id,
workspaceId: currentThread.workspace_id ?? workspaceId,
visibility,
hasComments: currentThread.has_comments ?? false,
});
}, [
activeThreadId,
currentThread,
currentThreadState.id,
currentThreadState.visibility,
workspaceId,
setCurrentThreadMetadata,
]);
// Handle new message from user
const onNew = useCallback(
(message: AppendMessage) => {
if (isThreadMessagesLoading) return Promise.resolve();
return startNewChat(buildCtx(), message);
},
[buildCtx, isThreadMessagesLoading]
);
// Cancel the in-flight turn (targets the active stream's owner thread).
const onCancel = useCallback(async () => {
await cancelActiveTurn();
}, []);
// Convert message (pass through since already in correct format)
const convertMessage = useCallback(
(message: ThreadMessageLike): ThreadMessageLike => message,
[]
);
// Handle editing a message - truncates history and regenerates with new
// query. When ``message.sourceId`` is set we pin ``from_message_id`` so the
// backend rewinds to the right checkpoint, and prompt the user to revert /
// continue / cancel before regenerating.
const onEdit = useCallback(
async (message: AppendMessage) => {
const { userQuery, userImages } = extractUserTurnForNewChatApi(message, []);
const queryForApi = userQuery.trim();
if (!queryForApi && userImages.length === 0) {
toast.error("Cannot edit with empty message");
return;
}
const userMessageContent = message.content as unknown as ThreadMessageLike["content"];
const sourceId = (message as { sourceId?: string }).sourceId;
const fromMessageId =
sourceId && /^msg-\d+$/.test(sourceId)
? Number.parseInt(sourceId.replace(/^msg-/, ""), 10)
: null;
if (fromMessageId == null) {
await regenerateChat(buildCtx(), queryForApi, {
userMessageContent,
userImages,
sourceUserMessageId: sourceId,
});
return;
}
const msgs = messagesRef.current;
const editedIndex = msgs.findIndex((m) => m.id === `msg-${fromMessageId}`);
let downstreamReversibleCount = 0;
let downstreamTotalCount = 0;
if (editedIndex >= 0) {
const downstream = msgs.slice(editedIndex + 1);
downstreamTotalCount = downstream.length;
const seenTurns = new Set<string>();
const downstreamTurnIds = new Set<string>();
for (const m of downstream) {
const meta = (m.metadata ?? {}) as { custom?: { chatTurnId?: string } };
const tid = meta.custom?.chatTurnId;
if (!tid || seenTurns.has(tid)) continue;
seenTurns.add(tid);
downstreamTurnIds.add(tid);
}
for (const a of agentActionItems) {
if (!a.chat_turn_id || !downstreamTurnIds.has(a.chat_turn_id)) continue;
if (
a.reversible &&
(a.reverted_by_action_id === null || a.reverted_by_action_id === undefined) &&
!a.is_revert_action &&
(a.error === null || a.error === undefined)
) {
downstreamReversibleCount += 1;
}
}
}
if (downstreamReversibleCount === 0) {
await regenerateChat(
buildCtx(),
queryForApi,
{ userMessageContent, userImages, sourceUserMessageId: sourceId },
{ fromMessageId, revertActions: false }
);
return;
}
setEditDialogState({
fromMessageId,
userQuery: queryForApi,
userMessageContent,
userImages,
downstreamReversibleCount,
downstreamTotalCount,
});
},
[buildCtx, agentActionItems]
);
const handleApprovalSubmit = useCallback(
(interruptId: string, decisions: HitlDecision[]) => {
// Stage this card's decisions; only fire the resume once every pending
// card in the current turn has submitted, so the backend slicer sees a
// single concatenated decisions list.
stagedDecisionsByInterruptIdRef.current.set(interruptId, decisions);
if (stagedDecisionsByInterruptIdRef.current.size < pendingInterrupts.length) {
return;
}
const ordered: HitlDecision[] = [];
for (const pi of pendingInterrupts) {
const staged = stagedDecisionsByInterruptIdRef.current.get(pi.interruptId);
if (!staged) {
return;
}
ordered.push(...staged);
}
stagedDecisionsByInterruptIdRef.current.clear();
window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions: ordered } }));
},
[pendingInterrupts]
);
const handleEditDialogChoice = useCallback(
async (choice: EditMessageDialogChoice) => {
const pending = editDialogState;
if (!pending) return;
setEditDialogState(null);
if (choice === "cancel") return;
await regenerateChat(
buildCtx(),
pending.userQuery,
{
userMessageContent: pending.userMessageContent,
userImages: pending.userImages,
sourceUserMessageId: `msg-${pending.fromMessageId}`,
},
{
fromMessageId: pending.fromMessageId,
revertActions: choice === "revert",
}
);
},
[editDialogState, buildCtx]
);
// Handle reloading/refreshing the last AI response
const onReload = useCallback(async () => {
await regenerateChat(buildCtx(), null);
}, [buildCtx]);
// HITL resume bridge. Submit always happens from this page's approval UI, so
// the currently-viewed thread owns the pending interrupts. Applies each
// decision to its card, then resumes the (durable) stream.
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail as {
decisions: Array<{
type: string;
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}>;
};
if (!detail?.decisions || pendingInterrupts.length === 0) return;
const incoming = detail.decisions;
if (incoming.length === 0) return;
const tcIds = pendingInterrupts.flatMap((p) => p.bundleToolCallIds);
const N = tcIds.length;
if (incoming.length !== N) {
toast.error(
`Cannot resume: ${incoming.length} decision(s) submitted for ${N} pending actions.`
);
return;
}
const byTcId = new Map<string, (typeof incoming)[number]>();
const submittedDecisions: typeof incoming = [];
for (let i = 0; i < tcIds.length; i++) {
const tcId = tcIds[i];
const decision = incoming[i];
if (tcId === undefined || decision === undefined) {
toast.error(
`Cannot resume: ${incoming.length} decision(s) submitted for ${N} pending actions.`
);
return;
}
byTcId.set(tcId, decision);
submittedDecisions.push(decision);
}
const targetAssistantMsgId = pendingInterrupts[0].assistantMsgId;
if (activeThreadId != null) {
chatStreamStore.setMessages(activeThreadId, (prev) =>
prev.map((m) => {
if (m.id !== targetAssistantMsgId) return m;
const parts = m.content as unknown as Array<Record<string, unknown>>;
const newContent = parts.map((part) => {
const tcId = part.toolCallId as string | undefined;
const d = tcId ? byTcId.get(tcId) : undefined;
if (!d || part.type !== "tool-call") return part;
if (typeof part.result !== "object" || part.result === null) return part;
if (!("__interrupt__" in (part.result as Record<string, unknown>))) return part;
const decided = d.type;
if (decided === "edit" && d.edited_action) {
return {
...part,
args: d.edited_action.args,
argsText: JSON.stringify(d.edited_action.args, null, 2),
result: {
...(part.result as Record<string, unknown>),
__decided__: decided,
},
};
}
return {
...part,
result: {
...(part.result as Record<string, unknown>),
__decided__: decided,
},
};
});
return { ...m, content: newContent as unknown as ThreadMessageLike["content"] };
})
);
}
void resumeChat(buildCtx(), submittedDecisions);
};
window.addEventListener("hitl-decision", handler);
return () => window.removeEventListener("hitl-decision", handler);
}, [buildCtx, pendingInterrupts, activeThreadId]);
// Surface the thread's deliverables to the layout-level artifacts sidebar.
useSyncChatArtifacts(runtimeMessages);
// Create external store runtime
const runtime = useExternalStoreRuntime({
messages: runtimeMessages,
isRunning,
onNew,
onEdit,
onReload,
convertMessage,
onCancel,
});
const threadLoadError = activeThreadId
? (threadDetailQuery.error ?? threadMessagesQuery.error)
: null;
const shouldShowThreadLoadError =
!!threadLoadError && !!activeThreadId && !currentThread && runtimeMessages.length === 0;
if (shouldShowThreadLoadError) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4">
<div className="text-destructive">Failed to load chat</div>
<Button
type="button"
onClick={() => {
void Promise.all([threadDetailQuery.refetch(), threadMessagesQuery.refetch()]);
}}
>
Try Again
</Button>
</div>
);
}
return (
<TokenUsageProvider store={tokenUsageStore}>
<AssistantRuntimeProvider runtime={runtime}>
<TimelineDataUI />
<StepSeparatorDataUI />
<PendingInterruptProvider
pendingInterrupts={pendingInterrupts}
onSubmit={handleApprovalSubmit}
>
<div key={workspaceId} className="flex h-full overflow-hidden">
<div className="relative flex-1 flex flex-col min-w-0 overflow-hidden">
<Thread
hasActiveThread={!!activeThreadId}
isLoadingMessages={isThreadMessagesLoading}
/>
</div>
<MobileReportPanel />
<MobileEditorPanel />
<MobileHitlEditPanel />
<MobileArtifactsPanel />
</div>
</PendingInterruptProvider>
<EditMessageDialog
open={editDialogState !== null}
onOpenChange={(open) => {
if (!open) setEditDialogState(null);
}}
downstreamReversibleCount={editDialogState?.downstreamReversibleCount ?? 0}
downstreamTotalCount={editDialogState?.downstreamTotalCount ?? 0}
onChoose={handleEditDialogChoice}
/>
</AssistantRuntimeProvider>
</TokenUsageProvider>
);
}

View file

@ -0,0 +1,60 @@
"use client";
import { useAtomValue } from "jotai";
import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
import {
llmSetupStatusAtomFamily,
modelConnectionsAtom,
} from "@/atoms/model-connections/model-connections-query.atoms";
import { Logo } from "@/components/Logo";
import { ModelProviderConnectionsPanel } from "@/components/settings/model-connections/model-provider-connections-panel";
import { Button } from "@/components/ui/button";
import { useSession } from "@/hooks/use-session";
import { redirectToLogin } from "@/lib/auth-utils";
export default function OnboardPage() {
const router = useRouter();
const params = useParams();
const workspaceId = Number(params.workspace_id);
const session = useSession();
const { data: connections = [] } = useAtomValue(modelConnectionsAtom);
const { data: setupStatus } = useAtomValue(llmSetupStatusAtomFamily(workspaceId));
useEffect(() => {
if (session.status === "unauthenticated") redirectToLogin();
}, [session.status]);
// Leaving onboarding is the layout gate's job; here we only enable the
// explicit CTA once the workspace can chat.
const isReady = setupStatus?.status === "ready";
return (
<div className="flex min-h-screen select-none flex-col items-center justify-center bg-main-panel p-4">
<div className="w-full max-w-3xl space-y-6 text-center">
<Logo className="mx-auto h-12 w-12" />
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">Choose a model</h1>
<p className="text-sm text-muted-foreground">
Connect any supported provider, then enable the models you want SurfSense to use.
</p>
</div>
<ModelProviderConnectionsPanel
workspaceId={workspaceId}
connections={connections}
className="flex flex-col gap-6 text-left"
footerAction={
<Button
className="min-w-[112px]"
disabled={!isReady}
onClick={() => router.replace(`/dashboard/${workspaceId}/new-chat`)}
>
Start
</Button>
}
showAddProviderHeader={false}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
export default async function WorkspaceDashboardPage({
params,
}: {
params: Promise<{ workspace_id: string }>;
}) {
const { workspace_id } = await params;
redirect(`/dashboard/${workspace_id}/new-chat`);
}

View file

@ -0,0 +1,15 @@
import { PlaygroundRunner } from "../../components/playground-runner";
export default async function PlaygroundVerbPage({
params,
}: {
params: Promise<{ workspace_id: string; platform: string; verb: string }>;
}) {
const { workspace_id, platform, verb } = await params;
return (
<div className="mx-auto w-full max-w-6xl">
<PlaygroundRunner workspaceId={Number(workspace_id)} platform={platform} verb={verb} />
</div>
);
}

View file

@ -0,0 +1,42 @@
import { Info } from "lucide-react";
import { WorkspaceApiAccessControl } from "@/components/settings/workspace-api-access-control";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import { ApiKeyContent } from "../../user-settings/components/ApiKeyContent";
export default async function PlaygroundApiKeysPage({
params,
}: {
params: Promise<{ workspace_id: string }>;
}) {
const { workspace_id } = await params;
const workspaceId = Number(workspace_id);
return (
<div className="mx-auto w-full max-w-5xl space-y-6">
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">API keys</h2>
<p className="text-sm text-muted-foreground">
Create user API keys and choose whether they can access this workspace.
</p>
</div>
<Alert>
<Info />
<AlertDescription>
External API calls need both a user API key and workspace API key access enabled.
</AlertDescription>
</Alert>
<section>
<WorkspaceApiAccessControl workspaceId={workspaceId} />
</section>
<Separator className="bg-border" />
<section>
<ApiKeyContent />
</section>
</div>
);
}

View file

@ -0,0 +1,121 @@
"use client";
import { Check, ChevronRight, Copy } from "lucide-react";
import { useMemo, useState } from "react";
import { JsonView } from "@/components/json-view";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BACKEND_URL } from "@/lib/env-config";
import { buildExamplePayload, buildSnippets } from "@/lib/playground/code-snippets";
import type { FormField } from "@/lib/playground/json-schema";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<Button
type="button"
variant="ghost"
size="sm"
onClick={copy}
aria-label={copied ? "Copied" : "Copy"}
className="absolute right-2 top-2 h-7 w-7 p-0"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
);
}
function CodeBlock({ code }: { code: string }) {
return (
<div className="relative">
<CopyButton text={code} />
<pre className="max-h-[420px] overflow-auto rounded-md border border-border/60 bg-muted/20 p-3 pr-20 text-xs">
<code>{code}</code>
</pre>
</div>
);
}
function SchemaBlock({ title, schema }: { title: string; schema: Record<string, unknown> }) {
const json = useMemo(() => JSON.stringify(schema, null, 2), [schema]);
return (
<details className="group rounded-md border border-border/60">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground [&::-webkit-details-marker]:hidden">
<span>{title}</span>
<ChevronRight className="h-4 w-4 shrink-0 transition-transform group-open:rotate-90" />
</summary>
<div className="relative border-t border-border/60">
<CopyButton text={json} />
<div className="max-h-[360px] overflow-auto p-3 pr-20">
<JsonView src={schema} collapsed={2} />
</div>
</div>
</details>
);
}
export function ApiReference({
workspaceId,
platform,
verb,
fields,
inputSchema,
outputSchema,
}: {
workspaceId: number;
platform: string;
verb: string;
fields: FormField[];
inputSchema: Record<string, unknown>;
/** Absent only when talking to a backend that predates output schemas. */
outputSchema?: Record<string, unknown>;
}) {
const path = `/api/v1/workspaces/${workspaceId}/scrapers/${platform}/${verb}`;
// In proxy mode BACKEND_URL is intentionally empty (same-origin), so external
// callers use this app's origin. Client component, so window is available.
const baseUrl = BACKEND_URL || (typeof window !== "undefined" ? window.location.origin : "");
const snippets = useMemo(() => {
const payload = buildExamplePayload(fields);
return buildSnippets(baseUrl, path, payload);
}, [fields, baseUrl, path]);
return (
<section className="space-y-4">
<div>
<h2 className="text-base font-semibold">API reference</h2>
<p className="mt-1 text-sm text-muted-foreground">
Create an API key, enable API access for this workspace, then use the examples below to
call this endpoint.
</p>
</div>
<Tabs defaultValue="curl">
<TabsList className="flex h-auto w-full flex-nowrap justify-start overflow-x-auto overflow-y-hidden">
{snippets.map((snippet) => (
<TabsTrigger key={snippet.id} value={snippet.id}>
{snippet.label}
</TabsTrigger>
))}
</TabsList>
{snippets.map((snippet) => (
<TabsContent key={snippet.id} value={snippet.id}>
<CodeBlock code={snippet.code} />
</TabsContent>
))}
</Tabs>
<div className="space-y-2">
<SchemaBlock title="Input schema (JSON)" schema={inputSchema} />
{outputSchema && <SchemaBlock title="Output schema (JSON)" schema={outputSchema} />}
</div>
</section>
);
}

View file

@ -0,0 +1,156 @@
"use client";
import { Check, Copy, Download } from "lucide-react";
import { useMemo, useState } from "react";
import { JsonView } from "@/components/json-view";
import { Button } from "@/components/ui/button";
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { downloadCsv, rowsToCsv } from "@/lib/playground/csv";
const MAX_TABLE_ROWS = 200;
function extractItems(data: unknown): Record<string, unknown>[] | null {
if (
typeof data === "object" &&
data !== null &&
"items" in data &&
Array.isArray((data as { items: unknown }).items)
) {
const items = (data as { items: unknown[] }).items;
if (items.every((item) => typeof item === "object" && item !== null)) {
return items as Record<string, unknown>[];
}
}
return null;
}
function cellText(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function ResultTable({ items }: { items: Record<string, unknown>[] }) {
const columns = useMemo(() => {
const keys = new Set<string>();
for (const item of items.slice(0, MAX_TABLE_ROWS)) {
for (const key of Object.keys(item)) keys.add(key);
}
return Array.from(keys);
}, [items]);
const rows = items.slice(0, MAX_TABLE_ROWS);
// overscroll-contain keeps table scrolling from chaining to the page.
return (
<div className="max-h-[480px] overflow-auto overscroll-contain rounded-md border border-border/60">
<table className="w-full caption-bottom text-sm">
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
{columns.map((col) => (
<TableHead key={col} className="whitespace-nowrap">
{col}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((item, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: rows have no stable id
<TableRow key={i}>
{columns.map((col) => (
<TableCell key={col} className="max-w-xs truncate align-top text-xs">
{cellText(item[col])}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</table>
</div>
);
}
export function OutputViewer({ data, filenameBase }: { data: unknown; filenameBase?: string }) {
const items = useMemo(() => extractItems(data), [data]);
const [view, setView] = useState<"table" | "json">(items ? "table" : "json");
const [copied, setCopied] = useState(false);
const json = useMemo(() => JSON.stringify(data, null, 2), [data]);
const copy = () => {
navigator.clipboard.writeText(json).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
// Export the full item set (not just the display-capped rows) as CSV, with a
// column per union key so nothing is dropped.
const exportCsv = () => {
if (!items || items.length === 0) return;
const columns = Array.from(
items.reduce((set, item) => {
for (const key of Object.keys(item)) set.add(key);
return set;
}, new Set<string>())
);
downloadCsv(filenameBase ?? "output", rowsToCsv(items, columns));
};
const truncated = items && items.length > MAX_TABLE_ROWS;
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Tabs value={view} onValueChange={(value) => setView(value as "table" | "json")}>
<TabsList className="h-auto">
{items && <TabsTrigger value="table">Table</TabsTrigger>}
<TabsTrigger value="json">JSON</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center gap-1">
{items && items.length > 0 && (
<Button type="button" variant="ghost" size="sm" onClick={exportCsv} className="gap-1.5">
<Download className="h-3.5 w-3.5" />
Export CSV
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={copy}
aria-label={copied ? "Copied JSON" : "Copy JSON"}
className="h-8 w-8 p-0"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
{items && items.length === 0 && (
<p className="rounded-md border border-dashed border-border/60 px-4 py-6 text-center text-sm text-muted-foreground">
No items returned.
</p>
)}
{view === "table" && items && items.length > 0 ? (
<>
<ResultTable items={items} />
{truncated && (
<p className="text-xs text-muted-foreground">
Showing first {MAX_TABLE_ROWS} of {items.length} items. Switch to JSON for the full
output.
</p>
)}
</>
) : (
<div className="max-h-[480px] overflow-auto overscroll-contain rounded-md border border-border/60 bg-muted/20 p-3">
<JsonView src={data} collapsed={2} />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,102 @@
"use client";
import { ArrowRight, History, Info, KeyRound } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
import { PLAYGROUND_PLATFORMS } from "@/lib/playground/catalog";
import { formatPricing } from "@/lib/playground/format";
export function PlaygroundIndex({ workspaceId }: { workspaceId: number }) {
const base = `/dashboard/${workspaceId}/playground`;
// The grid renders from the static catalog immediately; pricing fills in
// once the capabilities fetch lands (blank while loading, never blocking).
const { data: capabilities } = useScraperCapabilities(workspaceId);
const pricingByName = useMemo(
() => new Map(capabilities?.map((c) => [c.name, formatPricing(c.pricing)])),
[capabilities]
);
return (
<div className="space-y-8">
<Alert>
<Info />
<AlertDescription>
<p>
Manually run SurfSense's platform-native APIs and inspect their output. To use these
APIs outside SurfSense,{" "}
<Link
href={`${base}/api-keys`}
className="font-medium text-foreground underline-offset-4 hover:underline"
>
create an API key
</Link>
.
</p>
</AlertDescription>
</Alert>
<div className="grid gap-3 sm:grid-cols-2">
<Link
href={`${base}/runs`}
className="flex items-center justify-between rounded-lg border border-border/60 bg-accent/40 px-4 py-3 transition-colors hover:bg-accent"
>
<div className="flex items-center gap-3">
<History className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">API Runs</p>
<p className="text-xs text-muted-foreground">See every API run in this workspace</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
<Link
href={`${base}/api-keys`}
className="flex items-center justify-between rounded-lg border border-border/60 bg-accent/40 px-4 py-3 transition-colors hover:bg-accent"
>
<div className="flex items-center gap-3">
<KeyRound className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">API Keys</p>
<p className="text-xs text-muted-foreground">Manage keys and workspace API access</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
</div>
<div className="space-y-6">
{PLAYGROUND_PLATFORMS.map((platform) => (
<div key={platform.id} className="space-y-3">
<div className="flex items-center gap-2">
<platform.icon className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">{platform.label}</h2>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{platform.verbs.map((verb) => (
<Link
key={verb.name}
href={`${base}/${platform.id}/${verb.verb}`}
className="group flex flex-col rounded-lg border border-border/60 p-4 transition-colors hover:border-border hover:bg-muted/30"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{verb.label}</span>
<ArrowRight className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</div>
<code className="mt-2 text-xs text-muted-foreground">{verb.name}</code>
{pricingByName.has(verb.name) ? (
<span className="mt-2 text-xs tabular-nums text-muted-foreground">
{pricingByName.get(verb.name)}
</span>
) : null}
</Link>
))}
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,358 @@
"use client";
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";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useRunStream } from "@/hooks/use-run-stream";
import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
import { AppError } from "@/lib/error";
import { findVerb } from "@/lib/playground/catalog";
import { fieldErrorsFromError } from "@/lib/playground/field-errors";
import { formatCost, formatDuration, formatPricing } from "@/lib/playground/format";
import { buildPayload, initialFormValues, parseSchemaFields } from "@/lib/playground/json-schema";
import {
AmazonMarketplaceHint,
getAmazonFieldOptions,
hasAmazonFranceValue,
} from "@/lib/playground/platform-overrides/amazon";
import { urlFieldWarnings } from "@/lib/playground/url-hints";
import { ApiReference } from "./api-reference";
import { OutputViewer } from "./output-viewer";
import { RunProgressPanel } from "./run-progress-panel";
import { SchemaForm } from "./schema-form";
interface PlaygroundRunnerProps {
workspaceId: number;
platform: string;
verb: string;
}
const MAX_OUTPUT_LINES = 200;
/** Parse the stored JSONL output into objects, capped for rendering. */
function parseJsonlOutput(text: string | null | undefined): { items: unknown[] } {
if (!text) return { items: [] };
const lines = text.split("\n").filter((line) => line.trim().length > 0);
const items: unknown[] = [];
for (const line of lines.slice(0, MAX_OUTPUT_LINES)) {
try {
items.push(JSON.parse(line));
} catch {
items.push(line);
}
}
return { items };
}
function RunStat({
icon: Icon,
label,
value,
}: {
icon: typeof Hash;
label: string;
value: string;
}) {
return (
<div className="flex items-center gap-1.5 rounded-md border border-border/60 px-2.5 py-1.5">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-xs font-medium tabular-nums">{value}</span>
</div>
);
}
function getRunErrorMessage(error: unknown): string {
const status = error instanceof AppError ? error.status : undefined;
if (status === 402) {
return "Insufficient credits. Add credits to run this API.";
}
if (status === 422) {
if (Object.keys(fieldErrorsFromError(error)).length > 0) {
return "Invalid input. Check the highlighted fields.";
}
return error instanceof Error && error.message
? error.message
: "Invalid input. Check the fields above and try again.";
}
return error instanceof Error && error.message
? error.message
: "Something went wrong running this API.";
}
function EndpointCopyButton({ endpoint }: { endpoint: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(endpoint).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleCopy}
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"
>
<code className="min-w-0 break-all text-left sm:break-normal">{endpoint}</code>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
<span className="sr-only">{copied ? "Copied endpoint" : "Copy endpoint"}</span>
</Button>
);
}
export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunnerProps) {
const catalogVerb = findVerb(platform, verb);
const {
data: capabilities,
isLoading,
error: capabilitiesError,
} = useScraperCapabilities(workspaceId);
const capability = useMemo(() => {
if (!capabilities) return undefined;
const name = catalogVerb?.name ?? `${platform}.${verb}`;
return capabilities.find((c) => c.name === name);
}, [capabilities, catalogVerb, platform, verb]);
const fields = useMemo(() => parseSchemaFields(capability?.input_schema), [capability]);
const [values, setValues] = useState<Record<string, unknown>>({});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const run = useRunStream(workspaceId);
const isRunning = run.status === "running";
const previousStatusRef = useRef(run.status);
const notifiedRunRef = useRef<string | null>(null);
// Seed form defaults once the schema is available.
useEffect(() => {
if (fields.length > 0) {
setValues(initialFormValues(fields));
}
}, [fields]);
// Survive a refresh: if this verb's newest run is still ``running``, re-attach
// to its live stream instead of showing an idle form.
const reattachedRef = useRef(false);
const { reattach } = run;
useEffect(() => {
if (reattachedRef.current || !capability) return;
reattachedRef.current = true;
let cancelled = false;
(async () => {
try {
const recent = await scrapersApiService.listRuns(workspaceId, {
capability: capability.name,
limit: 1,
});
const top = recent[0];
if (!cancelled && top && top.status === "running") {
reattach(top.id);
}
} catch {
// Best-effort reattach; a fresh run still works.
}
})();
return () => {
cancelled = true;
};
}, [capability, workspaceId, reattach]);
const handleChange = (name: string, value: unknown) => {
setValues((prev) => ({ ...prev, [name]: value }));
setFieldErrors((prev) => {
if (!(name in prev)) return prev;
const { [name]: _cleared, ...rest } = prev;
return rest;
});
};
const handleRun = useCallback(() => {
setFieldErrors({});
const payload = buildPayload(fields, values);
void run.start(platform, verb, payload);
}, [fields, values, platform, verb, run]);
const output = useMemo(
() => (run.detail ? parseJsonlOutput(run.detail.output_text) : null),
[run.detail]
);
const endpoint = `POST /workspaces/${workspaceId}/scrapers/${platform}/${verb}`;
const isAmazonScrape = platform === "amazon" && verb === "scrape";
const hasAmazonFranceUrl = useMemo(() => hasAmazonFranceValue(values), [values]);
const fieldWarnings = useMemo(() => urlFieldWarnings(platform, values), [platform, values]);
useEffect(() => {
const previousStatus = previousStatusRef.current;
previousStatusRef.current = run.status;
if (previousStatus !== "running") return;
if (run.status === "success") {
const key = `${run.runId ?? "run"}:success`;
if (notifiedRunRef.current === key) return;
notifiedRunRef.current = key;
toast.success("API run completed.");
return;
}
if (run.status === "error") {
const nextFieldErrors = fieldErrorsFromError(run.error);
setFieldErrors(nextFieldErrors);
const key = `${run.runId ?? "run"}:error`;
if (notifiedRunRef.current === key) return;
notifiedRunRef.current = key;
// Field-level failures are shown inline (the form reveals + focuses the
// first one), so only toast global failures that have no field to anchor to.
// Keep run errors until dismissed — they're actionable and shouldn't
// vanish before the user reads them.
if (Object.keys(nextFieldErrors).length === 0) {
toast.error(getRunErrorMessage(run.error), {
duration: Number.POSITIVE_INFINITY,
closeButton: true,
});
}
}
}, [run.status, run.runId, run.error]);
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Spinner size="lg" />
</div>
);
}
if (capabilitiesError) {
return (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
Couldn't load API definitions
{capabilitiesError.message ? `: ${capabilitiesError.message}` : "."}
</div>
);
}
if (!capability || !catalogVerb) {
return (
<div className="rounded-md border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
Unknown API: {platform}.{verb}
</div>
);
}
return (
<div className="space-y-10">
<div className="space-y-6">
{capability.description && (
<Alert>
<Info />
<AlertDescription>
<p>
{capability.description}
{capability.docs_url ? (
<>
{" "}
<Link
href={capability.docs_url}
className="font-medium text-foreground underline-offset-4 hover:underline"
>
Read docs
</Link>{" "}
for more info.
</>
) : null}
</p>
</AlertDescription>
</Alert>
)}
<div className="space-y-5">
<div className="space-y-2">
<EndpointCopyButton endpoint={endpoint} />
<div className="text-xs text-muted-foreground">
<span>Pricing: </span>
<span className="font-medium tabular-nums text-foreground">
{formatPricing(capability.pricing)}
</span>
</div>
</div>
<SchemaForm
fields={fields}
values={values}
onChange={handleChange}
disabled={isRunning}
getFieldOptions={isAmazonScrape ? getAmazonFieldOptions : undefined}
fieldErrors={fieldErrors}
fieldWarnings={fieldWarnings}
/>
{isAmazonScrape ? <AmazonMarketplaceHint showFranceWarning={hasAmazonFranceUrl} /> : null}
<div className="flex items-center gap-2">
<Button type="button" onClick={handleRun} disabled={isRunning} className="relative">
<span className={isRunning ? "opacity-0" : ""}>Run</span>
{isRunning && <Spinner size="sm" className="absolute" />}
</Button>
{isRunning && (
<Button type="button" variant="secondary" onClick={run.cancel}>
Cancel
</Button>
)}
</div>
</div>
<div className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground">Output</h2>
{isRunning ? (
<RunProgressPanel latest={run.latest} events={run.events} elapsedMs={run.elapsedMs} />
) : run.status === "cancelled" ? (
<div className="flex h-64 items-center justify-center rounded-md border border-border/60 px-4 text-center text-sm text-muted-foreground">
Run cancelled.
</div>
) : run.status === "success" && output ? (
<>
<div className="flex flex-wrap gap-2">
<RunStat
icon={Hash}
label="Items"
value={String(run.detail?.item_count ?? output.items.length)}
/>
<RunStat
icon={Timer}
label="Time"
value={formatDuration(run.detail?.duration_ms ?? run.elapsedMs)}
/>
<RunStat icon={Coins} label="Cost" value={formatCost(run.detail?.cost_micros)} />
</div>
<OutputViewer data={output} filenameBase={`${platform}-${verb}`} />
</>
) : (
<div className="flex h-64 items-center justify-center rounded-md border border-dashed border-border/60 px-4 text-center text-sm text-muted-foreground">
Run the API to see output here.
</div>
)}
</div>
</div>
<ApiReference
workspaceId={workspaceId}
platform={platform}
verb={verb}
fields={fields}
inputSchema={capability.input_schema}
outputSchema={capability.output_schema}
/>
</div>
);
}

View file

@ -0,0 +1,112 @@
"use client";
import { useMemo } from "react";
import { JsonView } from "@/components/json-view";
import { Spinner } from "@/components/ui/spinner";
import { useScraperRun } from "@/hooks/use-scraper-runs";
import { OutputViewer } from "./output-viewer";
const MAX_OUTPUT_LINES = 200;
/** One-line label for a persisted ``run.progress`` event object. */
function progressLine(event: Record<string, unknown>): string {
const message = typeof event.message === "string" ? event.message : undefined;
const phase = typeof event.phase === "string" ? event.phase : undefined;
const current = typeof event.current === "number" ? event.current : undefined;
const total = typeof event.total === "number" ? event.total : undefined;
const base = message || (phase ? phase.replace(/_/g, " ") : "step");
if (current !== undefined) {
return `${base} (${total !== undefined ? `${current}/${total}` : current})`;
}
return base;
}
/** Parse the stored JSONL output into objects, capped for rendering. */
function parseJsonl(text: string | null): { items: unknown[]; total: number } {
if (!text) return { items: [], total: 0 };
const lines = text.split("\n").filter((line) => line.trim().length > 0);
const items: unknown[] = [];
for (const line of lines.slice(0, MAX_OUTPUT_LINES)) {
try {
items.push(JSON.parse(line));
} catch {
items.push(line);
}
}
return { items, total: lines.length };
}
export function RunDetail({ workspaceId, runId }: { workspaceId: number; runId: string }) {
const { data: run, isLoading, error } = useScraperRun(workspaceId, runId);
const parsed = useMemo(() => parseJsonl(run?.output_text ?? null), [run?.output_text]);
if (isLoading) {
return (
<div className="flex h-32 items-center justify-center">
<Spinner size="md" />
</div>
);
}
if (error) {
return (
<p className="p-4 text-sm text-destructive">
Couldn't load run{error.message ? `: ${error.message}` : "."}
</p>
);
}
if (!run) return null;
return (
<div className="space-y-4 border-t border-border/60 bg-muted/10 p-4">
{run.error && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
{run.error}
</div>
)}
{run.progress && run.progress.length > 0 && (
<div>
<h4 className="mb-1.5 text-xs font-medium text-muted-foreground">Progress</h4>
<div className="space-y-1 rounded-md border border-border/60 bg-background p-3 font-mono text-xs text-muted-foreground">
{run.progress.map((event, i) => (
<div key={i} className="truncate">
{progressLine(event)}
</div>
))}
</div>
</div>
)}
<div>
<h4 className="mb-1.5 text-xs font-medium text-muted-foreground">Input</h4>
<div className="rounded-md border border-border/60 bg-background p-3">
<JsonView src={run.input ?? {}} collapsed={2} />
</div>
</div>
<div>
<h4 className="mb-1.5 text-xs font-medium text-muted-foreground">
Output {parsed.total > 0 && `(${parsed.total} items)`}
</h4>
{run.output_text ? (
<>
<OutputViewer
data={{ items: parsed.items }}
filenameBase={`${run.capability}-${run.id}`}
/>
{parsed.total > MAX_OUTPUT_LINES && (
<p className="mt-2 text-xs text-muted-foreground">
Showing first {MAX_OUTPUT_LINES} of {parsed.total} stored items.
</p>
)}
</>
) : (
<p className="text-sm text-muted-foreground">No output stored for this run.</p>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,90 @@
"use client";
import { Loader2 } from "lucide-react";
import { useEffect, useRef } from "react";
import type { ScraperRunEvent } from "@/contracts/types/scraper.types";
import { formatDuration } from "@/lib/playground/format";
/** One-line human label for a progress/lifecycle event. */
function eventLabel(event: ScraperRunEvent): string {
const base =
event.message ||
(event.phase
? event.phase.replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase())
: "Working");
if (event.current !== undefined && event.current !== null) {
const counter =
event.total !== undefined && event.total !== null
? `${event.current}/${event.total}`
: String(event.current);
return `${base} (${counter})`;
}
return base;
}
/**
* Live progress for an in-flight async run: a headline status, a determinate
* bar when counts are known, a running elapsed timer, and a scrolling event log.
*/
export function RunProgressPanel({
latest,
events,
elapsedMs,
}: {
latest: ScraperRunEvent | null;
events: ScraperRunEvent[];
elapsedMs: number;
}) {
const logRef = useRef<HTMLDivElement>(null);
// Keep the newest line in view as the log grows.
useEffect(() => {
const el = logRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [events.length]);
const total = latest?.total ?? undefined;
const current = latest?.current ?? undefined;
const pct =
typeof current === "number" && typeof total === "number" && total > 0
? Math.min(100, Math.round((current / total) * 100))
: null;
return (
<div className="space-y-3 rounded-md border border-border/60 p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
<span className="truncate text-sm font-medium">
{latest ? eventLabel(latest) : "Starting…"}
</span>
</div>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{formatDuration(elapsedMs)}
</span>
</div>
{pct !== null && (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
)}
{events.length > 0 && (
<div
ref={logRef}
className="max-h-56 space-y-1 overflow-y-auto rounded border border-border/40 bg-muted/20 p-2 font-mono text-xs text-muted-foreground"
>
{events.map((event, i) => (
<div key={`${event.ts ?? i}-${i}`} className="truncate">
{eventLabel(event)}
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,36 @@
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
/** Scraper runs: ``running`` (async, in-flight), ``success``, ``error``, ``cancelled``. */
export function RunStatusBadge({ status }: { status: string }) {
const normalized = status.toLowerCase();
if (normalized === "running") {
return (
<Badge variant="secondary" className="gap-1 bg-blue-500/15 text-blue-600 dark:text-blue-400">
<Loader2 className="h-3 w-3 animate-spin" />
Running
</Badge>
);
}
if (normalized === "success") {
return (
<Badge
variant="secondary"
className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
>
Success
</Badge>
);
}
if (normalized === "error") {
return <Badge variant="destructive">Error</Badge>;
}
if (normalized === "cancelled") {
return (
<Badge variant="secondary" className="bg-amber-500/15 text-amber-600 dark:text-amber-400">
Cancelled
</Badge>
);
}
return <Badge variant="outline">{status}</Badge>;
}

Some files were not shown because too many files have changed in this diff Show more