mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
feat(marketing): CI-first homepage, connector API pages, and hero chat demo
- Homepage rebuilt around competitive-intelligence positioning: connector grid, how-it-works, use-case art, compare table, FAQ, and a scripted new-chat hero demo (typewriter prompt, agent timeline, streamed answer, /login on send) - Marketing pages for the Reddit/YouTube/Maps/SERP/Web Crawl scrape APIs at /<slug>, a /connectors hub, and a bespoke /mcp-connector page - Remove stale app/dashboard/[search_space_id] tree that broke the build (canonical route is [workspace_id]) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
80927a2872
commit
fb5b0da816
137 changed files with 4161 additions and 16017 deletions
94
surfsense_web/app/(home)/[slug]/page.tsx
Normal file
94
surfsense_web/app/(home)/[slug]/page.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
surfsense_web/app/(home)/connectors/page.tsx
Normal file
100
surfsense_web/app/(home)/connectors/page.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { ArrowRight, Plug } 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 data from the platforms your market uses 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",
|
||||
"competitive intelligence 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 your market uses
|
||||
</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">
|
||||
competitive intelligence 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 page (not in the scrape-API registry): SurfSense as an MCP client. */}
|
||||
<Link
|
||||
href="/mcp-connector"
|
||||
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">MCP Connector</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>
|
||||
);
|
||||
}
|
||||
369
surfsense_web/app/(home)/mcp-connector/page.tsx
Normal file
369
surfsense_web/app/(home)/mcp-connector/page.tsx
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
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/mcp-connector";
|
||||
|
||||
const metaDescription =
|
||||
"The SurfSense MCP connector lets your AI 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: "MCP Connector for AI Agents: Add Any MCP Server | SurfSense",
|
||||
description: metaDescription,
|
||||
keywords: [
|
||||
"mcp connector",
|
||||
"what is an mcp connector",
|
||||
"mcp client",
|
||||
"add mcp server",
|
||||
"connect mcp server",
|
||||
"mcp integrations",
|
||||
"mcp server for ai agents",
|
||||
],
|
||||
alternates: { canonical: canonicalUrl },
|
||||
openGraph: {
|
||||
title: "MCP Connector for AI Agents: Add Any MCP Server | SurfSense",
|
||||
description: metaDescription,
|
||||
url: canonicalUrl,
|
||||
siteName: "SurfSense",
|
||||
type: "website",
|
||||
images: [{ url: "/og-image.png", width: 1200, height: 630, alt: "SurfSense MCP connector" }],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "MCP Connector for AI Agents: 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 MCP connector?",
|
||||
answer:
|
||||
"An MCP connector links an AI application to an MCP (Model Context Protocol) server, so the app's agents can call the server's tools. In SurfSense, 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 an MCP connector different from an MCP server?",
|
||||
answer:
|
||||
"An MCP server exposes tools; an MCP connector consumes them. This page covers SurfSense acting as the client: plugging outside MCP servers into your agents. SurfSense also ships its own MCP server, which exposes connectors like Reddit and Google Maps as tools inside Claude, Cursor, or any MCP client.",
|
||||
},
|
||||
{
|
||||
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 McpConnectorPage() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: "SurfSense MCP Connector",
|
||||
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: "MCP Connector", href: "/mcp-connector" },
|
||||
]}
|
||||
/>
|
||||
<Badge variant="outline" className="mb-5 gap-1.5 py-1">
|
||||
<Plug className="size-3.5" />
|
||||
MCP connector
|
||||
</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-balance sm:text-4xl lg:text-5xl">
|
||||
Bring any MCP server to your AI agents
|
||||
</h1>
|
||||
<p className="mt-5 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">
|
||||
The SurfSense MCP connector turns your 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 market intelligence.
|
||||
</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">
|
||||
MCP connector vs MCP server
|
||||
</h2>
|
||||
<p className="mt-3 max-w-2xl text-muted-foreground leading-relaxed">
|
||||
They are two sides of the same protocol. The MCP connector on this page makes
|
||||
SurfSense a <em>client</em>: it consumes tools from outside MCP servers. The SurfSense
|
||||
MCP <em>server</em> does the reverse, exposing platform connectors 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">
|
||||
MCP connector: 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">
|
||||
The MCP connector is part of the SurfSense{" "}
|
||||
<Link href="/" className="font-medium text-foreground underline underline-offset-4">
|
||||
competitive intelligence 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="/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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 { HowItWorks } from "@/components/homepage/how-it-works";
|
||||
import { UseCasesRow } from "@/components/homepage/use-cases";
|
||||
|
||||
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 />
|
||||
<ConnectorGrid />
|
||||
<HowItWorks />
|
||||
<UseCasesRow />
|
||||
<CompareTable />
|
||||
<HomeFaq />
|
||||
<CommunityStrip />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { ArtifactsLibrary } from "@/features/artifacts-library";
|
||||
|
||||
export default function ArtifactsPage() {
|
||||
const params = useParams();
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
|
||||
return <ArtifactsLibrary searchSpaceId={searchSpaceId} />;
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
"use client";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { useAutomation } from "@/hooks/use-automation";
|
||||
import { useAutomationPermissions } from "../hooks/use-automation-permissions";
|
||||
import { AutomationDefinitionSection } from "./components/automation-definition-section";
|
||||
import { AutomationDetailHeader } from "./components/automation-detail-header";
|
||||
import { AutomationDetailLoading } from "./components/automation-detail-loading";
|
||||
import { AutomationNotFound } from "./components/automation-not-found";
|
||||
import { AutomationRunsSection } from "./components/automation-runs-section";
|
||||
import { AutomationTriggersSection } from "./components/automation-triggers-section";
|
||||
|
||||
interface AutomationDetailContentProps {
|
||||
searchSpaceId: number;
|
||||
automationId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client orchestrator for one automation's detail view. Branches:
|
||||
* - permissions loading → skeleton
|
||||
* - no read permission → access denied panel
|
||||
* - bad id (NaN) → not-found panel
|
||||
* - detail fetching → skeleton
|
||||
* - detail error / null → not-found panel (we don't distinguish 404
|
||||
* from 403 in the UI)
|
||||
* - detail loaded → header + definition + triggers
|
||||
*
|
||||
* Each child component is gated independently on the relevant permission
|
||||
* so the orchestrator stays thin.
|
||||
*/
|
||||
export function AutomationDetailContent({
|
||||
searchSpaceId,
|
||||
automationId,
|
||||
}: AutomationDetailContentProps) {
|
||||
const perms = useAutomationPermissions();
|
||||
const validId = Number.isInteger(automationId) && automationId > 0;
|
||||
const { data: automation, isLoading, error } = useAutomation(validId ? automationId : undefined);
|
||||
|
||||
if (perms.loading) {
|
||||
return <AutomationDetailLoading />;
|
||||
}
|
||||
|
||||
if (!perms.canRead) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-6 py-12 text-center">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!validId) {
|
||||
return <AutomationNotFound searchSpaceId={searchSpaceId} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <AutomationDetailLoading />;
|
||||
}
|
||||
|
||||
if (error || !automation) {
|
||||
return <AutomationNotFound searchSpaceId={searchSpaceId} error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutomationDetailHeader
|
||||
automation={automation}
|
||||
searchSpaceId={searchSpaceId}
|
||||
canUpdate={perms.canUpdate}
|
||||
canDelete={perms.canDelete}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 min-w-0 lg:col-span-2">
|
||||
<AutomationDefinitionSection definition={automation.definition} />
|
||||
<AutomationRunsSection automationId={automation.id} />
|
||||
</div>
|
||||
<div className="space-y-6 min-w-0">
|
||||
<AutomationTriggersSection
|
||||
triggers={automation.triggers}
|
||||
automationId={automation.id}
|
||||
canUpdate={perms.canUpdate}
|
||||
canDelete={perms.canDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
"use client";
|
||||
import { Dot } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import type { AutomationDefinition } from "@/contracts/types/automation.types";
|
||||
import { ExecutionSummary } from "./execution-summary";
|
||||
import { InputsSchemaPreview } from "./inputs-schema-preview";
|
||||
import { PlanStepCard } from "./plan-step-card";
|
||||
|
||||
interface AutomationDefinitionSectionProps {
|
||||
definition: AutomationDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing read view of the saved automation definition. Editing happens on
|
||||
* the sibling /edit route; this card should summarize behavior, not expose the
|
||||
* raw persisted schema.
|
||||
*/
|
||||
export function AutomationDefinitionSection({ definition }: AutomationDefinitionSectionProps) {
|
||||
const hasTags = definition.metadata.tags.length > 0;
|
||||
const hasInputs = !!definition.inputs;
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const stepCount = `${definition.plan.length} step${definition.plan.length === 1 ? "" : "s"}`;
|
||||
|
||||
return (
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-semibold">Automation details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{definition.goal && (
|
||||
<Field label="Goal">
|
||||
<p className="text-sm text-foreground">{definition.goal}</p>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{hasTags && (
|
||||
<Field label="Tags">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{definition.metadata.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{hasInputs && (
|
||||
<Field label="Inputs">
|
||||
{definition.inputs && <InputsSchemaPreview inputs={definition.inputs} />}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label={
|
||||
<span className="inline-flex items-center">
|
||||
Plan
|
||||
<Dot className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
{stepCount}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{definition.plan.map((step, idx) => (
|
||||
<PlanStepCard key={step.step_id} step={step} index={idx} />
|
||||
))}
|
||||
</div>
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="mt-3">
|
||||
<CollapsibleTrigger className="text-xs font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline">
|
||||
{advancedOpen ? "Hide advanced options" : "Advanced options"}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-3 rounded-md border border-border/60 bg-background/30 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-muted-foreground">
|
||||
Execution defaults
|
||||
</div>
|
||||
<ExecutionSummary execution={definition.execution} />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-muted-foreground">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
"use client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Pause, Pencil, Play, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
import { updateAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { Automation } from "@/contracts/types/automation.types";
|
||||
import { DeleteAutomationDialog } from "../../components/delete-automation-dialog";
|
||||
|
||||
interface AutomationDetailHeaderProps {
|
||||
automation: Automation;
|
||||
searchSpaceId: number;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Title bar for the detail page: back link, name, status badge,
|
||||
* description, and the two destructive-ish primary actions (pause /
|
||||
* resume + delete). Same mutation atoms as the list-row actions to
|
||||
* keep caches coherent.
|
||||
*
|
||||
* Archived automations hide the pause/resume toggle (we don't unarchive
|
||||
* here — that flow comes later if we need it).
|
||||
*/
|
||||
export function AutomationDetailHeader({
|
||||
automation,
|
||||
searchSpaceId,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
}: AutomationDetailHeaderProps) {
|
||||
const router = useRouter();
|
||||
const { mutateAsync: updateAutomation, isPending: updating } = useAtomValue(
|
||||
updateAutomationMutationAtom
|
||||
);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const canToggle = canUpdate && automation.status !== "archived";
|
||||
const nextStatus = automation.status === "active" ? "paused" : "active";
|
||||
const pauseLabel = automation.status === "active" ? "Pause" : "Resume";
|
||||
const PauseIcon = automation.status === "active" ? Pause : Play;
|
||||
|
||||
const handleDeleted = useCallback(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/automations`);
|
||||
}, [router, searchSpaceId]);
|
||||
|
||||
async function handleTogglePause() {
|
||||
await updateAutomation({
|
||||
automationId: automation.id,
|
||||
patch: { status: nextStatus },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||
Back to automations
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-2 min-w-0 flex-1">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground break-words">
|
||||
{automation.name}
|
||||
</h1>
|
||||
{automation.description && (
|
||||
<p className="text-sm text-muted-foreground max-w-3xl">{automation.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{canUpdate && (
|
||||
<Button
|
||||
asChild
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-md bg-muted px-3 hover:bg-accent"
|
||||
>
|
||||
<Link href={`/dashboard/${searchSpaceId}/automations/${automation.id}/edit`}>
|
||||
<Pencil className="mr-1 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{canToggle && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleTogglePause}
|
||||
disabled={updating}
|
||||
className="relative justify-start rounded-md bg-muted px-3 hover:bg-accent"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
updating
|
||||
? "inline-flex items-center whitespace-nowrap opacity-0"
|
||||
: "inline-flex items-center whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<PauseIcon className="mr-1 h-4 w-4" />
|
||||
{pauseLabel}
|
||||
</span>
|
||||
{updating && (
|
||||
<Spinner
|
||||
size="xs"
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className="justify-start rounded-md bg-muted px-3 hover:bg-accent"
|
||||
>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canDelete && (
|
||||
<DeleteAutomationDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
automationId={automation.id}
|
||||
automationName={automation.name}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
"use client";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Skeleton for the detail page. Mirrors the loaded view's main/sidebar
|
||||
* grid (Definition + Runs on the left, Triggers on the right) so layout
|
||||
* doesn't reflow when data arrives.
|
||||
*/
|
||||
export function AutomationDetailLoading() {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-7 w-64" />
|
||||
<Skeleton className="h-5 w-16 rounded-md" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-96" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 min-w-0 lg:col-span-2">
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-6 min-w-0">
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
"use client";
|
||||
import { ArrowLeft, FileWarning } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AutomationNotFoundProps {
|
||||
searchSpaceId: number;
|
||||
error?: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered when the detail fetch fails (404 / 403 / network) or the id
|
||||
* is not a number. We don't distinguish "missing" from "forbidden" in the
|
||||
* UI on purpose — leaking that an id exists you can't read is worse than
|
||||
* a vague message.
|
||||
*/
|
||||
export function AutomationNotFound({ searchSpaceId, 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 />
|
||||
<h2 className="mt-3 text-base font-semibold text-foreground">Automation not found</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
|
||||
This automation doesn't exist or you don't have access to it.
|
||||
{error?.message ? ` (${error.message})` : null}
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm" className="mt-6">
|
||||
<Link href={`/dashboard/${searchSpaceId}/automations`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to automations
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
"use client";
|
||||
import { History } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useAutomationRuns } from "@/hooks/use-automation-runs";
|
||||
import { RunRow } from "./run-row";
|
||||
import { RunsLoading } from "./runs-loading";
|
||||
|
||||
interface AutomationRunsSectionProps {
|
||||
automationId: number;
|
||||
}
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
/**
|
||||
* Run history card. Shows the most recent ``LIMIT`` runs; pagination is
|
||||
* intentionally deferred — for the foreseeable v1 surface (one-trigger
|
||||
* automations firing daily), 20 covers ~3 weeks of history which is
|
||||
* enough to tell whether things are working. Real "load more" lands if
|
||||
* we see usage spike past that.
|
||||
*/
|
||||
export function AutomationRunsSection({ automationId }: AutomationRunsSectionProps) {
|
||||
const { data, isLoading, error } = useAutomationRuns(automationId, { limit: LIMIT });
|
||||
const runs = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base font-semibold inline-flex items-center gap-2">
|
||||
Recent runs
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Most recent first. Click a row to inspect step results, output and artifacts.
|
||||
</p>
|
||||
</div>
|
||||
{!isLoading && !error && data && (
|
||||
<span className="text-xs text-muted-foreground">{data.total} total</span>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<RunsLoading />
|
||||
) : error ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Couldn't load runs{error.message ? `: ${error.message}` : "."}
|
||||
</p>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center">
|
||||
<History className="mx-auto h-8 w-8 text-muted-foreground" aria-hidden />
|
||||
<p className="mt-2 text-sm font-medium text-foreground">No runs yet</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This automation hasn't fired. Once a trigger fires (or you invoke it manually), runs
|
||||
will appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{runs.map((run) => (
|
||||
<RunRow key={run.id} run={run} automationId={automationId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
"use client";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { Trigger } from "@/contracts/types/automation.types";
|
||||
import { TriggerCard } from "./trigger-card";
|
||||
|
||||
interface AutomationTriggersSectionProps {
|
||||
triggers: Trigger[];
|
||||
automationId: number;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Triggers card. Lists each attached trigger with its own enable
|
||||
* toggle and remove button. v1 attaches triggers at automation-creation
|
||||
* time only; there is no in-place "add trigger" affordance here.
|
||||
*/
|
||||
export function AutomationTriggersSection({
|
||||
triggers,
|
||||
automationId,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
}: AutomationTriggersSectionProps) {
|
||||
return (
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-semibold">Triggers</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">When this automation runs</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{triggers.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border/60 bg-muted/20 px-4 py-8 text-center">
|
||||
<CalendarClock className="mx-auto h-8 w-8 text-muted-foreground" aria-hidden />
|
||||
<p className="mt-2 text-sm font-medium text-foreground">No triggers attached</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This automation can still be invoked, but nothing will fire it on its own.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{triggers.map((trigger) => (
|
||||
<TriggerCard
|
||||
key={trigger.id}
|
||||
trigger={trigger}
|
||||
automationId={automationId}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
"use client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { removeTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
interface DeleteTriggerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
automationId: number;
|
||||
triggerId: number;
|
||||
triggerLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm + detach one trigger from its automation. The automation itself
|
||||
* is untouched; only this trigger row is removed. The mutation atom
|
||||
* invalidates the parent automation detail so the page rerenders.
|
||||
*/
|
||||
export function DeleteTriggerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
automationId,
|
||||
triggerId,
|
||||
triggerLabel,
|
||||
}: DeleteTriggerDialogProps) {
|
||||
const { mutateAsync: removeTrigger } = useAtomValue(removeTriggerMutationAtom);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleConfirm() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await removeTrigger({ automationId, triggerId });
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove this trigger?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<span className="font-medium text-foreground">{triggerLabel}</span> will be detached.
|
||||
The automation itself stays, but it won't fire on this trigger anymore.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={submitting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
disabled={submitting}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{submitting ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Spinner size="xs" />
|
||||
Removing…
|
||||
</span>
|
||||
) : (
|
||||
"Remove"
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
"use client";
|
||||
import type { Execution } from "@/contracts/types/automation.types";
|
||||
|
||||
interface ExecutionSummaryProps {
|
||||
execution: Execution;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact view of an automation's execution defaults (wall-clock cap,
|
||||
* retries, backoff, concurrency, on_failure presence). Per-step overrides
|
||||
* are shown inside each PlanStepCard, not here.
|
||||
*/
|
||||
export function ExecutionSummary({ execution }: ExecutionSummaryProps) {
|
||||
return (
|
||||
<dl className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-2 text-xs">
|
||||
<Item label="Timeout" value={`${execution.timeout_seconds}s`} />
|
||||
<Item label="Max retries" value={String(execution.max_retries)} />
|
||||
<Item label="Retry backoff" value={formatEnumValue(execution.retry_backoff)} />
|
||||
<Item label="Concurrency" value={formatEnumValue(execution.concurrency)} />
|
||||
{execution.on_failure.length > 0 && (
|
||||
<Item
|
||||
label="On failure"
|
||||
value={`${execution.on_failure.length} step${execution.on_failure.length === 1 ? "" : "s"}`}
|
||||
/>
|
||||
)}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function formatEnumValue(value: string): string {
|
||||
const text = value.replace(/_/g, " ");
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
function Item({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="text-foreground font-medium truncate">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
"use client";
|
||||
import type { Inputs } from "@/contracts/types/automation.types";
|
||||
|
||||
interface InputsSchemaPreviewProps {
|
||||
inputs: Inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only preview of an automation's accepted-inputs schema. Most
|
||||
* automations don't define inputs (defaults are baked into the trigger's
|
||||
* static_inputs), so the parent skips rendering this card when ``inputs``
|
||||
* is null.
|
||||
*/
|
||||
export function InputsSchemaPreview({ inputs }: InputsSchemaPreviewProps) {
|
||||
const fields = getInputFields(inputs.schema);
|
||||
|
||||
if (fields.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No extra inputs are required.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 bg-background/30">
|
||||
{fields.map((field) => (
|
||||
<div
|
||||
key={field.name}
|
||||
className="flex items-start justify-between gap-4 border-border/60 px-3 py-2 text-sm not-last:border-b"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-foreground">{field.name}</div>
|
||||
{field.description ? (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">{field.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
{field.type}
|
||||
{field.required ? " · required" : ""}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getInputFields(schema: Record<string, unknown>): {
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
required: boolean;
|
||||
}[] {
|
||||
const properties = schema.properties;
|
||||
if (!properties || typeof properties !== "object" || Array.isArray(properties)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
||||
return Object.entries(properties as Record<string, unknown>).map(([name, value]) => {
|
||||
const field = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
return {
|
||||
name,
|
||||
type: formatType((field as Record<string, unknown>).type),
|
||||
description:
|
||||
typeof (field as Record<string, unknown>).description === "string"
|
||||
? ((field as Record<string, unknown>).description as string)
|
||||
: undefined,
|
||||
required: required.has(name),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function formatType(value: unknown): string {
|
||||
if (Array.isArray(value)) return value.join(" or ");
|
||||
if (typeof value === "string") return value;
|
||||
return "value";
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
"use client";
|
||||
import type { PlanStep } from "@/contracts/types/automation.types";
|
||||
|
||||
interface PlanStepCardProps {
|
||||
step: PlanStep;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only view of one plan step. Keep this user-facing: summarize what the
|
||||
* step does and only show advanced step controls when they are explicitly set.
|
||||
*/
|
||||
export function PlanStepCard({ step, index }: PlanStepCardProps) {
|
||||
const title = getStepTitle(step);
|
||||
const details = getStepDetails(step);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 bg-background/30 px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-medium text-foreground">{title}</h3>
|
||||
{details.length > 0 ? (
|
||||
<dl className="mt-3 grid grid-cols-1 gap-x-6 gap-y-1.5 text-xs sm:grid-cols-2">
|
||||
{details.map((detail) => (
|
||||
<DefRow key={detail.label} label={detail.label} value={detail.value} />
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2 min-w-0">
|
||||
<dt className="text-muted-foreground shrink-0">{label}:</dt>
|
||||
<dd className="text-foreground min-w-0 truncate">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStepTitle(step: PlanStep): string {
|
||||
if (step.action === "agent_task") {
|
||||
return readStringParam(step.params, "query") ?? "Run an agent task";
|
||||
}
|
||||
return sentenceCase(formatAction(step.action));
|
||||
}
|
||||
|
||||
function getStepDetails(step: PlanStep): { label: string; value: string }[] {
|
||||
const details: { label: string; value: string }[] = [];
|
||||
|
||||
if (step.action === "agent_task") {
|
||||
if (typeof step.params.auto_approve_all === "boolean") {
|
||||
details.push({
|
||||
label: "Approval",
|
||||
value: step.params.auto_approve_all ? "Auto-approve agent actions" : "Ask before actions",
|
||||
});
|
||||
}
|
||||
|
||||
const mentionSummary = summarizeMentions(step.params);
|
||||
if (mentionSummary) {
|
||||
details.push({ label: "Scope", value: mentionSummary });
|
||||
}
|
||||
} else {
|
||||
const readableParams = Object.entries(step.params)
|
||||
.filter(([, value]) => value !== null && value !== undefined && value !== "")
|
||||
.map(([key, value]) => `${sentenceCase(formatKey(key))}: ${formatValue(value)}`);
|
||||
if (readableParams.length > 0) {
|
||||
details.push({ label: "Details", value: readableParams.join(" · ") });
|
||||
}
|
||||
}
|
||||
|
||||
if (step.when) details.push({ label: "Runs when", value: step.when });
|
||||
if (step.output_as) details.push({ label: "Saves output as", value: step.output_as });
|
||||
if (step.max_retries != null)
|
||||
details.push({ label: "Max retries", value: String(step.max_retries) });
|
||||
if (step.timeout_seconds != null)
|
||||
details.push({ label: "Timeout", value: `${step.timeout_seconds}s` });
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
function readStringParam(params: Record<string, unknown>, key: string): string | null {
|
||||
const value = params[key];
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function summarizeMentions(params: Record<string, unknown>): string | null {
|
||||
const parts: string[] = [];
|
||||
addMentionTitles(parts, params.mentioned_documents, "Documents and folders");
|
||||
addMentionTitles(parts, params.mentioned_connectors, "Connectors");
|
||||
if (parts.length === 0) {
|
||||
addCount(parts, params.mentioned_document_ids, "document");
|
||||
addCount(parts, params.mentioned_folder_ids, "folder");
|
||||
addCount(parts, params.mentioned_connector_ids, "connector");
|
||||
}
|
||||
return parts.length > 0 ? parts.join(", ") : null;
|
||||
}
|
||||
|
||||
function addMentionTitles(parts: string[], value: unknown, label: string): void {
|
||||
if (!Array.isArray(value) || value.length === 0) return;
|
||||
const titles = value
|
||||
.map((entry) => {
|
||||
const record = asRecord(entry);
|
||||
const title = typeof record.title === "string" ? record.title : null;
|
||||
const accountName = typeof record.account_name === "string" ? record.account_name : null;
|
||||
return title ?? accountName;
|
||||
})
|
||||
.filter((title): title is string => !!title);
|
||||
if (titles.length === 0) return;
|
||||
parts.push(`${label}: ${titles.join(", ")}`);
|
||||
}
|
||||
|
||||
function addCount(parts: string[], value: unknown, singular: string): void {
|
||||
if (!Array.isArray(value) || value.length === 0) return;
|
||||
parts.push(`${value.length} ${singular}${value.length === 1 ? "" : "s"}`);
|
||||
}
|
||||
|
||||
function formatAction(action: string): string {
|
||||
return formatKey(action);
|
||||
}
|
||||
|
||||
function formatKey(key: string): string {
|
||||
return key.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function sentenceCase(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
if (typeof value === "string" || typeof value === "number") return String(value);
|
||||
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`;
|
||||
if (value && typeof value === "object") return "Configured";
|
||||
return String(value);
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
"use client";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
FileOutput,
|
||||
GitCommitHorizontal,
|
||||
Package,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { JsonView } from "@/components/json-view";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { RunStatus, RunStepResult } from "@/contracts/types/automation.types";
|
||||
import { useAutomationRun } from "@/hooks/use-automation-runs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RunStepResultCard } from "./run-step-result-card";
|
||||
|
||||
interface RunDetailsPanelProps {
|
||||
automationId: number;
|
||||
runId: number;
|
||||
/** Live step entries from Zero; rendered while the run is in-flight and
|
||||
* also kept as the authoritative source once it finishes. */
|
||||
liveSteps: RunStepResult[];
|
||||
/** Live run status from Zero. Used to hide diagnostic sections that
|
||||
* only make sense after the run reaches a terminal state. */
|
||||
liveStatus: RunStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded view of a single run. Steps render immediately from the live
|
||||
* Zero row so the panel updates as the run progresses; the heavy REST
|
||||
* payload (output, artifacts, resolved inputs, run-level error) is
|
||||
* fetched lazily and merged in when it arrives.
|
||||
*
|
||||
* Surfacing order is outcome-first: a run-level error (when present),
|
||||
* then per-step cards that render the agent's markdown ``final_message``
|
||||
* directly, and finally the structural artifacts/inputs. The full
|
||||
* ``definition_snapshot`` is omitted because it usually mirrors the live
|
||||
* definition — surfacing it would dominate the panel without informing
|
||||
* what the user is trying to learn ("did this work? what did it do?").
|
||||
*/
|
||||
export function RunDetailsPanel({
|
||||
automationId,
|
||||
runId,
|
||||
liveSteps,
|
||||
liveStatus,
|
||||
}: RunDetailsPanelProps) {
|
||||
const isTerminal = liveStatus !== "pending" && liveStatus !== "running";
|
||||
// Defer the REST round-trip until the run can actually carry heavy
|
||||
// fields — output/artifacts/error are only written at terminal mark.
|
||||
const {
|
||||
data: run,
|
||||
isLoading,
|
||||
error,
|
||||
} = useAutomationRun(automationId, runId, {
|
||||
enabled: isTerminal,
|
||||
});
|
||||
|
||||
const runError = run?.error && Object.keys(run.error).length > 0 ? run.error : null;
|
||||
const hasOutput = !!run?.output && Object.keys(run.output).length > 0;
|
||||
const hasInputs = !!run && Object.keys(run.inputs ?? {}).length > 0;
|
||||
const hasDiagnostics = !!run && (run.artifacts.length > 0 || hasInputs);
|
||||
const heavyLoading = isTerminal && isLoading && !run;
|
||||
const heavyError = isTerminal && !!error;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border-t border-border/60 bg-muted/20 p-4">
|
||||
{runError ? <RunErrorSection error={runError} /> : null}
|
||||
|
||||
{hasOutput ? (
|
||||
<Section icon={FileOutput} label="Output">
|
||||
<JsonBlock value={run.output} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section icon={GitCommitHorizontal} label={`Step results · ${liveSteps.length}`}>
|
||||
{liveSteps.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isTerminal ? "No steps recorded." : "Waiting for first step…"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{liveSteps.map((step, index) => (
|
||||
<RunStepResultCard key={step.step_id ?? index} step={step} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{heavyLoading ? (
|
||||
<Skeleton className="h-16 w-full" />
|
||||
) : heavyError ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Couldn't load run details{error?.message ? `: ${error.message}` : "."}
|
||||
</p>
|
||||
) : hasDiagnostics ? (
|
||||
<>
|
||||
<Separator className="bg-border/60" />
|
||||
{run && run.artifacts.length > 0 ? (
|
||||
<Section icon={Package} label={`Artifacts · ${run.artifacts.length}`}>
|
||||
<JsonBlock value={run.artifacts} />
|
||||
</Section>
|
||||
) : null}
|
||||
{hasInputs ? (
|
||||
<Section icon={Settings2} label="Resolved inputs">
|
||||
<JsonBlock value={run?.inputs} />
|
||||
</Section>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run-level error: a readable destructive alert when a message is present,
|
||||
* with the full structured error available behind a raw toggle.
|
||||
*/
|
||||
function RunErrorSection({ error }: { error: Record<string, unknown> }) {
|
||||
const [rawOpen, setRawOpen] = useState(false);
|
||||
const message = typeof error.message === "string" ? error.message : null;
|
||||
const type = typeof error.type === "string" ? error.type : "Run failed";
|
||||
|
||||
return (
|
||||
<Section icon={AlertCircle} label="Error" tone="destructive">
|
||||
{message ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle aria-hidden />
|
||||
<AlertTitle>{type}</AlertTitle>
|
||||
<AlertDescription className="wrap-break-word">{message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Collapsible open={rawOpen} onOpenChange={setRawOpen} className="mt-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-fit px-2 text-xs text-muted-foreground"
|
||||
aria-expanded={rawOpen}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"transition-transform motion-reduce:transition-none",
|
||||
rawOpen && "rotate-180"
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{rawOpen ? "Hide raw" : "View raw"}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ScrollArea className="mt-2 max-h-64 rounded-md bg-muted/40 px-3 py-2">
|
||||
<JsonView src={error} collapsed={1} />
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
icon: Icon,
|
||||
label,
|
||||
tone = "default",
|
||||
children,
|
||||
}: {
|
||||
icon: typeof AlertCircle;
|
||||
label: string;
|
||||
tone?: "default" | "destructive";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider",
|
||||
tone === "destructive" ? "text-destructive" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" aria-hidden />
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonBlock({ value }: { value: unknown }) {
|
||||
return (
|
||||
<ScrollArea className="max-h-64 rounded-md bg-muted/40 px-3 py-2">
|
||||
<JsonView src={value} collapsed={1} />
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
"use client";
|
||||
import { ChevronDown, ChevronRight, Hand } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { LiveRunSummary } from "@/hooks/use-automation-runs";
|
||||
import { formatDuration } from "@/lib/automations/run-duration";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { RunDetailsPanel } from "./run-details-panel";
|
||||
import { RunStatusBadge } from "./run-status-badge";
|
||||
|
||||
interface RunRowProps {
|
||||
run: LiveRunSummary;
|
||||
automationId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One run row. Click to expand → renders the details panel inline.
|
||||
* Status and step_results come live from the parent's Zero query; the
|
||||
* panel itself only fetches the heavy REST fields on first expand.
|
||||
*/
|
||||
export function RunRow({ run, automationId }: RunRowProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const duration = formatDuration(run.started_at, run.finished_at);
|
||||
const startedLabel = run.started_at
|
||||
? formatRelativeDate(run.started_at)
|
||||
: formatRelativeDate(run.created_at);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="flex w-full items-center justify-between gap-4 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{open ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden />
|
||||
)}
|
||||
<RunStatusBadge status={run.status} />
|
||||
<span className="text-xs text-muted-foreground truncate">{startedLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 text-xs text-muted-foreground">
|
||||
{duration && <span className="font-mono">{duration}</span>}
|
||||
<TriggerSource triggerId={run.trigger_id ?? null} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<RunDetailsPanel
|
||||
automationId={automationId}
|
||||
runId={run.id}
|
||||
liveSteps={run.step_results}
|
||||
liveStatus={run.status}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TriggerSource({ triggerId }: { triggerId: number | null }) {
|
||||
if (triggerId == null) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Hand className="h-3 w-3" aria-hidden />
|
||||
Manual
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span>via trigger #{triggerId}</span>;
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
"use client";
|
||||
import { AlertCircle, CheckCircle2, Clock, Loader2, TimerOff, XCircle } from "lucide-react";
|
||||
import type { RunStatus } from "@/contracts/types/automation.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STATUS_STYLES: Record<
|
||||
RunStatus,
|
||||
{ label: string; icon: typeof CheckCircle2; classes: string; spin?: boolean }
|
||||
> = {
|
||||
pending: {
|
||||
label: "Pending",
|
||||
icon: Clock,
|
||||
classes: "bg-muted text-muted-foreground border-border/60",
|
||||
},
|
||||
running: {
|
||||
label: "Running",
|
||||
icon: Loader2,
|
||||
classes: "bg-blue-500/10 text-blue-600 border-blue-500/20",
|
||||
spin: true,
|
||||
},
|
||||
succeeded: {
|
||||
label: "Succeeded",
|
||||
icon: CheckCircle2,
|
||||
classes: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20",
|
||||
},
|
||||
failed: {
|
||||
label: "Failed",
|
||||
icon: XCircle,
|
||||
classes: "bg-destructive/10 text-destructive border-destructive/20",
|
||||
},
|
||||
cancelled: {
|
||||
label: "Cancelled",
|
||||
icon: AlertCircle,
|
||||
classes: "bg-muted text-muted-foreground border-border/60",
|
||||
},
|
||||
timed_out: {
|
||||
label: "Timed out",
|
||||
icon: TimerOff,
|
||||
classes: "bg-amber-500/10 text-amber-600 border-amber-500/20",
|
||||
},
|
||||
};
|
||||
|
||||
export function RunStatusBadge({ status, className }: { status: RunStatus; className?: string }) {
|
||||
const { label, icon: Icon, classes, spin } = STATUS_STYLES[status];
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium",
|
||||
classes,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-3 w-3", spin && "animate-spin")} aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
"use client";
|
||||
import { CheckCircle2, ChevronDown, MinusCircle, XCircle } from "lucide-react";
|
||||
import { memo, useState } from "react";
|
||||
import { JsonView } from "@/components/json-view";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type { RunStepResult } from "@/contracts/types/automation.types";
|
||||
import { formatDuration } from "@/lib/automations/run-duration";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type BadgeVariant = React.ComponentProps<typeof Badge>["variant"];
|
||||
|
||||
const STATUS_BADGE: Record<
|
||||
string,
|
||||
{ label: string; variant: BadgeVariant; icon: typeof CheckCircle2 }
|
||||
> = {
|
||||
succeeded: { label: "Succeeded", variant: "outline", icon: CheckCircle2 },
|
||||
failed: { label: "Failed", variant: "destructive", icon: XCircle },
|
||||
skipped: { label: "Skipped", variant: "secondary", icon: MinusCircle },
|
||||
};
|
||||
|
||||
function StepStatusBadge({ status }: { status: string }) {
|
||||
const meta = STATUS_BADGE[status] ?? {
|
||||
label: status,
|
||||
variant: "outline" as const,
|
||||
icon: MinusCircle,
|
||||
};
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<Badge variant={meta.variant} className="shrink-0">
|
||||
<Icon aria-hidden />
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One step from a run's ``step_results``. Surfaces the agent's markdown
|
||||
* ``final_message`` first-class (rendered, not raw), shows step errors as a
|
||||
* readable alert, and keeps the full structured payload behind a "View raw"
|
||||
* collapsible escape hatch.
|
||||
*/
|
||||
export const RunStepResultCard = memo(function RunStepResultCard({
|
||||
step,
|
||||
}: {
|
||||
step: RunStepResult;
|
||||
}) {
|
||||
const [rawOpen, setRawOpen] = useState(false);
|
||||
|
||||
const duration = formatDuration(step.started_at, step.finished_at);
|
||||
const attempts = step.attempts ?? 0;
|
||||
const finalMessage =
|
||||
typeof step.result?.final_message === "string" ? step.result.final_message : null;
|
||||
const errorMessage = step.error?.message;
|
||||
const hasMeta = Boolean(duration) || attempts > 1;
|
||||
|
||||
return (
|
||||
<Card className="border-border/60 shadow-none">
|
||||
<CardHeader className="gap-2 space-y-0 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs font-medium">{step.action}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{step.step_id}</span>
|
||||
</div>
|
||||
<StepStatusBadge status={step.status} />
|
||||
</div>
|
||||
{hasMeta ? (
|
||||
<div className="flex items-center gap-3 text-[11px] text-muted-foreground tabular-nums">
|
||||
{duration ? <span>{duration}</span> : null}
|
||||
{attempts > 1 ? <span>{attempts} attempts</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-3 p-3 pt-0">
|
||||
{errorMessage ? (
|
||||
<Alert variant="destructive">
|
||||
<XCircle aria-hidden />
|
||||
<AlertTitle>{step.error?.type ?? "Error"}</AlertTitle>
|
||||
<AlertDescription className="wrap-break-word">{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{finalMessage ? (
|
||||
<div className="min-w-0 wrap-break-word rounded-md border border-border/60 bg-background px-3 py-2">
|
||||
<MarkdownViewer content={finalMessage} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Collapsible open={rawOpen} onOpenChange={setRawOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-fit px-2 text-xs text-muted-foreground"
|
||||
aria-expanded={rawOpen}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"transition-transform motion-reduce:transition-none",
|
||||
rawOpen && "rotate-180"
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{rawOpen ? "Hide raw" : "View raw"}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ScrollArea className="mt-2 max-h-64 rounded-md bg-muted/40 px-3 py-2">
|
||||
<JsonView src={step} collapsed={1} />
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
"use client";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const ROW_KEYS = ["a", "b", "c"] as const;
|
||||
|
||||
export function RunsLoading() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{ROW_KEYS.map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between gap-4 rounded-md border border-border/60 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-5 w-20 rounded-md" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,386 +0,0 @@
|
|||
"use client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { updateTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { type Trigger, triggerUpdateRequest } from "@/contracts/types/automation.types";
|
||||
import { describeCron } from "@/lib/automations/describe-cron";
|
||||
import {
|
||||
DEFAULT_SCHEDULE,
|
||||
fromCron,
|
||||
type ScheduleFrequency,
|
||||
toCron,
|
||||
} from "@/lib/automations/schedule-builder";
|
||||
import { formatRelativeFutureDate } from "@/lib/format-date";
|
||||
import { TimezoneCombobox } from "../../components/builder/timezone-combobox";
|
||||
import { DeleteTriggerDialog } from "./delete-trigger-dialog";
|
||||
|
||||
interface TriggerCardProps {
|
||||
trigger: Trigger;
|
||||
automationId: number;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
type SimpleFrequency = Extract<ScheduleFrequency, "hourly" | "daily" | "weekdays"> | "custom";
|
||||
|
||||
interface TriggerDraft {
|
||||
frequency: SimpleFrequency;
|
||||
hour: number;
|
||||
minute: number;
|
||||
timezone: string;
|
||||
cron: string;
|
||||
}
|
||||
|
||||
const SIMPLE_FREQUENCIES = new Set<ScheduleFrequency>(["hourly", "daily", "weekdays"]);
|
||||
|
||||
function draftFromTrigger(trigger: Trigger): TriggerDraft {
|
||||
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : "";
|
||||
const timezone = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC";
|
||||
const model = fromCron(cron);
|
||||
if (model && SIMPLE_FREQUENCIES.has(model.frequency)) {
|
||||
return {
|
||||
frequency: model.frequency as SimpleFrequency,
|
||||
hour: model.hour,
|
||||
minute: model.minute,
|
||||
timezone,
|
||||
cron,
|
||||
};
|
||||
}
|
||||
return {
|
||||
frequency: "custom",
|
||||
hour: DEFAULT_SCHEDULE.hour,
|
||||
minute: DEFAULT_SCHEDULE.minute,
|
||||
timezone,
|
||||
cron,
|
||||
};
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
return value.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
function clampInt(raw: string, min: number, max: number): number {
|
||||
const value = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(value)) return min;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* One trigger row in the Triggers section of the detail page. Renders:
|
||||
* - human-readable schedule
|
||||
* - compact enable toggle
|
||||
* - dropdown actions for edit/remove
|
||||
*
|
||||
* Inline edit keeps schedule editing intentionally small: common frequencies,
|
||||
* time, timezone, and raw cron only for schedules outside the simple model.
|
||||
* ``enabled`` stays on the Switch so the two surfaces don't fight.
|
||||
*/
|
||||
export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: TriggerCardProps) {
|
||||
const { mutateAsync: updateTrigger, isPending: updating } =
|
||||
useAtomValue(updateTriggerMutationAtom);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<TriggerDraft>(() => draftFromTrigger(trigger));
|
||||
const [issues, setIssues] = useState<string[]>([]);
|
||||
|
||||
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : undefined;
|
||||
const human = cron ? describeCron(cron) : trigger.type;
|
||||
const triggerLabel = human;
|
||||
const showActions = (canUpdate && !isEditing) || canDelete;
|
||||
|
||||
async function handleToggle(checked: boolean) {
|
||||
await updateTrigger({
|
||||
automationId,
|
||||
triggerId: trigger.id,
|
||||
patch: { enabled: checked },
|
||||
});
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
setDraft(draftFromTrigger(trigger));
|
||||
setIssues([]);
|
||||
setIsEditing(true);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setIsEditing(false);
|
||||
setIssues([]);
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
setIssues([]);
|
||||
const params =
|
||||
draft.frequency === "custom"
|
||||
? { cron: draft.cron.trim(), timezone: draft.timezone }
|
||||
: {
|
||||
cron: toCron({
|
||||
...DEFAULT_SCHEDULE,
|
||||
frequency: draft.frequency,
|
||||
hour: draft.hour,
|
||||
minute: draft.minute,
|
||||
}),
|
||||
timezone: draft.timezone,
|
||||
};
|
||||
const result = triggerUpdateRequest.safeParse({
|
||||
params,
|
||||
static_inputs: trigger.static_inputs ?? {},
|
||||
});
|
||||
if (!result.success) {
|
||||
setIssues(
|
||||
result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateTrigger({
|
||||
automationId,
|
||||
triggerId: trigger.id,
|
||||
patch: result.data,
|
||||
});
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
setIssues([(err as Error).message ?? "Update failed"]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border border-border/60 bg-background/30">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="min-w-0 truncate text-sm font-medium text-foreground">{human}</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{canUpdate && (
|
||||
<Switch
|
||||
checked={trigger.enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={updating || isEditing}
|
||||
aria-label={trigger.enabled ? "Disable trigger" : "Enable trigger"}
|
||||
className="h-5 w-9 [&>span]:h-4 [&>span]:w-4 [&>span[data-state=checked]]:translate-x-4"
|
||||
/>
|
||||
)}
|
||||
{showActions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 hover:bg-transparent"
|
||||
disabled={isEditing}
|
||||
aria-label="Trigger actions"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32 z-80">
|
||||
{canUpdate && !isEditing && (
|
||||
<DropdownMenuItem onSelect={startEdit}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem onSelect={() => setDeleteOpen(true)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEditing && trigger.next_fire_at ? (
|
||||
<div className="flex items-center gap-3 border-t border-border/60 px-4 py-3 text-sm">
|
||||
<div className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<span>Next fire:</span>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
trigger.enabled
|
||||
? "min-w-0 truncate font-medium text-foreground"
|
||||
: "min-w-0 truncate text-muted-foreground"
|
||||
}
|
||||
title={new Date(trigger.next_fire_at).toLocaleString()}
|
||||
>
|
||||
{formatRelativeFutureDate(trigger.next_fire_at)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-3 border-t border-border/60 px-4 py-3 text-xs">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="trigger-runs">
|
||||
Runs
|
||||
</label>
|
||||
<Select
|
||||
value={draft.frequency}
|
||||
onValueChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, frequency: value as SimpleFrequency }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="trigger-runs" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hourly">Every hour</SelectItem>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="weekdays">Weekdays</SelectItem>
|
||||
<SelectItem value="custom">Custom cron</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{draft.frequency === "hourly" ? (
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
htmlFor="trigger-minute"
|
||||
>
|
||||
At minute
|
||||
</label>
|
||||
<Input
|
||||
id="trigger-minute"
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={draft.minute}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
minute: clampInt(event.target.value, 0, 59),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : draft.frequency !== "custom" ? (
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
htmlFor="trigger-time"
|
||||
>
|
||||
Time
|
||||
</label>
|
||||
<Input
|
||||
id="trigger-time"
|
||||
type="time"
|
||||
value={`${pad(draft.hour)}:${pad(draft.minute)}`}
|
||||
onChange={(event) => {
|
||||
const [hour, minute] = event.target.value.split(":");
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
hour: clampInt(hour, 0, 23),
|
||||
minute: clampInt(minute, 0, 59),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
htmlFor="trigger-cron"
|
||||
>
|
||||
Schedule expression
|
||||
</label>
|
||||
<Input
|
||||
id="trigger-cron"
|
||||
value={draft.cron}
|
||||
placeholder="0 9 * * 1-5"
|
||||
className="font-mono"
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({ ...prev, cron: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Timezone</div>
|
||||
<TimezoneCombobox
|
||||
value={draft.timezone}
|
||||
onChange={(timezone) => setDraft((prev) => ({ ...prev, timezone }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{issues.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle aria-hidden />
|
||||
<AlertTitle>
|
||||
{issues.length === 1 ? "1 issue" : `${issues.length} issues`}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-inside list-disc">
|
||||
{issues.map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={cancelEdit}
|
||||
disabled={updating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={saveEdit}
|
||||
disabled={updating}
|
||||
className="relative"
|
||||
>
|
||||
<span className={updating ? "opacity-0" : undefined}>Save</span>
|
||||
{updating ? (
|
||||
<Spinner
|
||||
size="xs"
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
/>
|
||||
) : null}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canDelete && (
|
||||
<DeleteTriggerDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
automationId={automationId}
|
||||
triggerId={trigger.id}
|
||||
triggerLabel={triggerLabel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
"use client";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { useAutomation } from "@/hooks/use-automation";
|
||||
import { AutomationBuilderForm } from "../../components/builder/automation-builder-form";
|
||||
import { useAutomationPermissions } from "../../hooks/use-automation-permissions";
|
||||
import { AutomationDetailLoading } from "../components/automation-detail-loading";
|
||||
import { AutomationNotFound } from "../components/automation-not-found";
|
||||
import { AutomationEditHeader } from "./components/automation-edit-header";
|
||||
|
||||
interface AutomationEditContentProps {
|
||||
searchSpaceId: number;
|
||||
automationId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client orchestrator for the edit route. Mirrors detail-content's branch
|
||||
* 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) {
|
||||
const perms = useAutomationPermissions();
|
||||
const validId = Number.isInteger(automationId) && automationId > 0;
|
||||
const { data: automation, isLoading, error } = useAutomation(validId ? automationId : undefined);
|
||||
|
||||
if (perms.loading) {
|
||||
return <AutomationDetailLoading />;
|
||||
}
|
||||
|
||||
if (!perms.canUpdate) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-6 py-12 text-center">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!validId) {
|
||||
return <AutomationNotFound searchSpaceId={searchSpaceId} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <AutomationDetailLoading />;
|
||||
}
|
||||
|
||||
if (error || !automation) {
|
||||
return <AutomationNotFound searchSpaceId={searchSpaceId} error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AutomationBuilderForm
|
||||
mode="edit"
|
||||
searchSpaceId={searchSpaceId}
|
||||
automation={automation}
|
||||
renderModeSwitcher={(modeSwitcher) => (
|
||||
<AutomationEditHeader
|
||||
automation={automation}
|
||||
searchSpaceId={searchSpaceId}
|
||||
modeSwitcher={modeSwitcher}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
"use client";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Automation } from "@/contracts/types/automation.types";
|
||||
|
||||
interface AutomationEditHeaderProps {
|
||||
automation: Automation;
|
||||
searchSpaceId: number;
|
||||
modeSwitcher?: ReactNode;
|
||||
}
|
||||
|
||||
export function AutomationEditHeader({
|
||||
automation,
|
||||
searchSpaceId,
|
||||
modeSwitcher,
|
||||
}: AutomationEditHeaderProps) {
|
||||
const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Button asChild variant="ghost" size="sm" className="-ml-2 h-auto px-2 py-1">
|
||||
<Link href={detailHref} className="text-xs text-muted-foreground">
|
||||
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||
Back to automation
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground wrap-break-word">
|
||||
Edit automation
|
||||
</h1>
|
||||
{modeSwitcher ? <div className="ml-auto">{modeSwitcher}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { AutomationEditContent } from "./automation-edit-content";
|
||||
|
||||
export default async function AutomationEditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string; automation_id: string }>;
|
||||
}) {
|
||||
const { search_space_id, automation_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<AutomationEditContent
|
||||
searchSpaceId={Number(search_space_id)}
|
||||
automationId={Number(automation_id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { AutomationDetailContent } from "./automation-detail-content";
|
||||
|
||||
export default async function AutomationDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string; automation_id: string }>;
|
||||
}) {
|
||||
const { search_space_id, automation_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<AutomationDetailContent
|
||||
searchSpaceId={Number(search_space_id)}
|
||||
automationId={Number(automation_id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
"use client";
|
||||
import { AlertCircle, ShieldAlert } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { useAutomations } from "@/hooks/use-automations";
|
||||
import { AutomationsEmptyState } from "./components/automations-empty-state";
|
||||
import { AutomationsHeader } from "./components/automations-header";
|
||||
import { AutomationsTable } from "./components/automations-table";
|
||||
import { useAutomationPermissions } from "./hooks/use-automation-permissions";
|
||||
|
||||
interface AutomationsContentProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client orchestrator for the automations list page. Pulls the active
|
||||
* search space'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) {
|
||||
const { automations, total, loading, error } = useAutomations();
|
||||
const perms = useAutomationPermissions();
|
||||
|
||||
if (perms.loading) {
|
||||
// Permissions gate the entire page; defer everything until we know.
|
||||
return (
|
||||
<>
|
||||
<AutomationsHeader searchSpaceId={searchSpaceId} total={0} loading canCreate={false} />
|
||||
<AutomationsTable
|
||||
automations={[]}
|
||||
searchSpaceId={searchSpaceId}
|
||||
loading
|
||||
canUpdate={false}
|
||||
canDelete={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!perms.canRead) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-6 py-12 text-center">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<AutomationsHeader
|
||||
searchSpaceId={searchSpaceId}
|
||||
total={0}
|
||||
loading={false}
|
||||
canCreate={perms.canCreate}
|
||||
/>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle aria-hidden />
|
||||
<AlertDescription>Couldn't load automations {error.message}</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!loading && automations.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<AutomationsHeader
|
||||
searchSpaceId={searchSpaceId}
|
||||
total={0}
|
||||
loading={false}
|
||||
canCreate={perms.canCreate}
|
||||
showCreateCta={false}
|
||||
/>
|
||||
<AutomationsEmptyState searchSpaceId={searchSpaceId} canCreate={perms.canCreate} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutomationsHeader
|
||||
searchSpaceId={searchSpaceId}
|
||||
total={total}
|
||||
loading={loading}
|
||||
canCreate={perms.canCreate}
|
||||
/>
|
||||
<AutomationsTable
|
||||
automations={automations}
|
||||
searchSpaceId={searchSpaceId}
|
||||
loading={loading}
|
||||
canUpdate={perms.canUpdate}
|
||||
canDelete={perms.canDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
"use client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { MoreHorizontal, Pause, Play, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { updateAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { AutomationSummary } from "@/contracts/types/automation.types";
|
||||
import { DeleteAutomationDialog } from "./delete-automation-dialog";
|
||||
|
||||
interface AutomationRowActionsProps {
|
||||
automation: AutomationSummary;
|
||||
searchSpaceId: number;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-dot menu on each row: pause/resume (if updatable) and delete
|
||||
* (if deletable). The menu itself is hidden when the user has neither
|
||||
* permission so we don't render an empty trigger.
|
||||
*/
|
||||
export function AutomationRowActions({
|
||||
automation,
|
||||
searchSpaceId,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
}: AutomationRowActionsProps) {
|
||||
const { mutateAsync: updateAutomation, isPending: updating } = useAtomValue(
|
||||
updateAutomationMutationAtom
|
||||
);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
if (!canUpdate && !canDelete) return null;
|
||||
|
||||
const nextStatus = automation.status === "active" ? "paused" : "active";
|
||||
const pauseLabel = automation.status === "active" ? "Pause" : "Resume";
|
||||
const PauseIcon = automation.status === "active" ? Pause : Play;
|
||||
const canToggle = canUpdate && automation.status !== "archived";
|
||||
|
||||
async function handleTogglePause() {
|
||||
await updateAutomation({
|
||||
automationId: automation.id,
|
||||
patch: { status: nextStatus },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 hover:bg-transparent"
|
||||
aria-label={`Actions for ${automation.name}`}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32 z-80">
|
||||
{canToggle && (
|
||||
<DropdownMenuItem onSelect={handleTogglePause} disabled={updating}>
|
||||
<PauseIcon className="mr-2 h-4 w-4" />
|
||||
{pauseLabel}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem onSelect={() => setDeleteOpen(true)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{canDelete && (
|
||||
<DeleteAutomationDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
automationId={automation.id}
|
||||
automationName={automation.name}
|
||||
searchSpaceId={searchSpaceId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
"use client";
|
||||
import Link from "next/link";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import type { AutomationSummary } from "@/contracts/types/automation.types";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { AutomationRowActions } from "./automation-row-actions";
|
||||
import { AutomationStatusBadge } from "./automation-status-badge";
|
||||
|
||||
interface AutomationRowProps {
|
||||
automation: AutomationSummary;
|
||||
searchSpaceId: number;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in the automations table. The name links to the detail page;
|
||||
* actions are gated by ``canUpdate`` / ``canDelete``. Trigger summary
|
||||
* is intentionally left to the detail page — list responses don't
|
||||
* include triggers and we want to avoid N+1 detail fetches.
|
||||
*/
|
||||
export function AutomationRow({
|
||||
automation,
|
||||
searchSpaceId,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
}: AutomationRowProps) {
|
||||
return (
|
||||
<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}`}
|
||||
className="block truncate text-sm font-medium text-foreground hover:underline"
|
||||
>
|
||||
{automation.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-2.5 border-r border-border/60 w-32 align-middle">
|
||||
<AutomationStatusBadge status={automation.status} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell px-4 py-2.5 border-r border-border/60 w-40 align-middle text-xs text-muted-foreground">
|
||||
{formatRelativeDate(automation.updated_at)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 md:px-6 py-2.5 w-16 align-middle">
|
||||
<div className="flex justify-end">
|
||||
<AutomationRowActions
|
||||
automation={automation}
|
||||
searchSpaceId={searchSpaceId}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
"use client";
|
||||
import type { AutomationStatus } from "@/contracts/types/automation.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AutomationStatusBadgeProps {
|
||||
status: AutomationStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Small borderless status pills, matching model-selector badges.
|
||||
const STATUS_STYLES: Record<AutomationStatus, { label: string; classes: string }> = {
|
||||
active: {
|
||||
label: "Active",
|
||||
classes: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300",
|
||||
},
|
||||
paused: {
|
||||
label: "Paused",
|
||||
classes: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
|
||||
},
|
||||
archived: {
|
||||
label: "Archived",
|
||||
classes: "bg-muted text-muted-foreground",
|
||||
},
|
||||
};
|
||||
|
||||
export function AutomationStatusBadge({ status, className }: AutomationStatusBadgeProps) {
|
||||
const { label, classes } = STATUS_STYLES[status];
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-md border-0 px-1.5 py-0 text-sm font-medium leading-5",
|
||||
classes,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
"use client";
|
||||
import { CalendarClock, Pause } from "lucide-react";
|
||||
import type { Trigger } from "@/contracts/types/automation.types";
|
||||
import { describeCron } from "@/lib/automations/describe-cron";
|
||||
|
||||
interface AutomationTriggersSummaryProps {
|
||||
triggers: Trigger[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line summary of an automation's triggers for the list view.
|
||||
*
|
||||
* v1 only registers ``schedule`` so this stays compact:
|
||||
* - 0 triggers → "No triggers"
|
||||
* - 1 schedule trigger → "Mon–Fri at 09:00 · UTC" + disabled badge if off
|
||||
* - >1 → "N triggers"
|
||||
*
|
||||
* The detail page renders the full per-trigger editor.
|
||||
*/
|
||||
export function AutomationTriggersSummary({ triggers }: AutomationTriggersSummaryProps) {
|
||||
if (triggers.length === 0) {
|
||||
return <span className="text-xs text-muted-foreground">No triggers</span>;
|
||||
}
|
||||
|
||||
if (triggers.length > 1) {
|
||||
return <span className="text-xs text-muted-foreground">{triggers.length} triggers</span>;
|
||||
}
|
||||
|
||||
const [trigger] = triggers;
|
||||
|
||||
if (trigger.type === "schedule") {
|
||||
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : undefined;
|
||||
const tz = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC";
|
||||
const human = cron ? describeCron(cron) : "Schedule";
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<CalendarClock className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
<span className="text-foreground">{human}</span>
|
||||
<span className="text-muted-foreground">· {tz}</span>
|
||||
{!trigger.enabled && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md border border-border/60 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<Pause className="h-2.5 w-2.5" aria-hidden />
|
||||
Off
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-xs text-muted-foreground capitalize">{trigger.type}</span>;
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
"use client";
|
||||
import { AlarmClock } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AutomationsEmptyStateProps {
|
||||
searchSpaceId: number;
|
||||
canCreate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-state for the automations list. The primary CTA points to a new
|
||||
* chat — creation happens via the ``create_automation`` HITL tool, not a
|
||||
* "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) {
|
||||
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">
|
||||
<AlarmClock className="h-6 w-6" aria-hidden />
|
||||
</div>
|
||||
<h3 className="mt-4 text-base font-semibold text-foreground">No automations yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
|
||||
Automations let SurfSense run agent tasks on a schedule. Describe what you want in chat and
|
||||
SurfSense drafts the automation for your approval.
|
||||
</p>
|
||||
{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>
|
||||
</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>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-6 text-xs text-muted-foreground">
|
||||
You don't have permission to create automations in this search space.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
"use client";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AutomationsHeaderProps {
|
||||
searchSpaceId: number;
|
||||
total: number;
|
||||
loading: boolean;
|
||||
canCreate: boolean;
|
||||
/**
|
||||
* Render the header's Create CTA. Defaults to true; the empty state owns
|
||||
* the primary CTA on its own card, so the orchestrator turns this off
|
||||
* there to avoid a duplicate button.
|
||||
*/
|
||||
showCreateCta?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page header: title + count + "Create via chat" CTA. Creation is intent-driven
|
||||
* (the create_automation tool runs inside chat with a HITL approval card), so
|
||||
* the CTA links to a new chat rather than opening a form. Model eligibility is
|
||||
* handled per-automation in the builder + approval card, not gated here.
|
||||
*/
|
||||
export function AutomationsHeader({
|
||||
searchSpaceId,
|
||||
total,
|
||||
loading,
|
||||
canCreate,
|
||||
showCreateCta = true,
|
||||
}: AutomationsHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground">Automations</h1>
|
||||
{!loading && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{total} {total === 1 ? "automation" : "automations"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canCreate && showCreateCta && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="justify-start rounded-md bg-muted px-3 hover:bg-accent"
|
||||
>
|
||||
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>Create manually</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>Create via chat</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
"use client";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
|
||||
const ROW_KEYS = ["sk-1", "sk-2", "sk-3"];
|
||||
|
||||
/**
|
||||
* Skeleton rows for the automations table. Number of rows is fixed since
|
||||
* we don't know the count ahead of time and three placeholders is enough
|
||||
* to communicate "loading" without flashing too much chrome.
|
||||
*/
|
||||
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>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 border-r border-border/60 w-32">
|
||||
<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">
|
||||
<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>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
"use client";
|
||||
import { AlarmClock, CalendarDays, Info } from "lucide-react";
|
||||
import { Table, TableBody, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import type { AutomationSummary } from "@/contracts/types/automation.types";
|
||||
import { AutomationRow } from "./automation-row";
|
||||
import { AutomationsLoadingRows } from "./automations-loading";
|
||||
|
||||
interface AutomationsTableProps {
|
||||
automations: AutomationSummary[];
|
||||
searchSpaceId: number;
|
||||
loading: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table shell + header. Rows render below — loading state renders skeleton
|
||||
* rows in the same shell so the layout doesn't shift on data arrival.
|
||||
*/
|
||||
export function AutomationsTable({
|
||||
automations,
|
||||
searchSpaceId,
|
||||
loading,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
}: AutomationsTableProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-accent overflow-hidden">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-b border-border/60">
|
||||
<TableHead className="px-4 md:px-6 border-r border-border/60">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<AlarmClock size={14} className="opacity-60 text-muted-foreground" />
|
||||
Name
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="border-r border-border/60 w-32">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<Info size={14} className="opacity-60 text-muted-foreground" />
|
||||
Status
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell border-r border-border/60 w-40">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<CalendarDays size={14} className="opacity-60 text-muted-foreground" />
|
||||
Updated
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="px-4 md:px-6 w-16">
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<AutomationsLoadingRows />
|
||||
) : (
|
||||
automations.map((automation) => (
|
||||
<AutomationRow
|
||||
key={automation.id}
|
||||
automation={automation}
|
||||
searchSpaceId={searchSpaceId}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { BuilderExecution } from "@/lib/automations/builder-schema";
|
||||
import { Field } from "./form-field";
|
||||
|
||||
interface AdvancedSectionProps {
|
||||
execution: BuilderExecution;
|
||||
tags: string[];
|
||||
onExecutionChange: (patch: Partial<BuilderExecution>) => void;
|
||||
onTagsChange: (tags: string[]) => void;
|
||||
}
|
||||
|
||||
const BACKOFF_OPTIONS: ReadonlyArray<{ value: BuilderExecution["retryBackoff"]; label: string }> = [
|
||||
{ value: "exponential", label: "Exponential" },
|
||||
{ value: "linear", label: "Linear" },
|
||||
{ value: "none", label: "None" },
|
||||
];
|
||||
|
||||
const CONCURRENCY_OPTIONS: ReadonlyArray<{
|
||||
value: BuilderExecution["concurrency"];
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "drop_if_running", label: "Skip if already running" },
|
||||
{ value: "queue", label: "Queue the next run" },
|
||||
{ value: "always", label: "Always run" },
|
||||
];
|
||||
|
||||
function clampInt(raw: string, min: number, fallback: number): number {
|
||||
const value = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(value)) return fallback;
|
||||
return Math.max(min, value);
|
||||
}
|
||||
|
||||
export function AdvancedSection({
|
||||
execution,
|
||||
tags,
|
||||
onExecutionChange,
|
||||
onTagsChange,
|
||||
}: AdvancedSectionProps) {
|
||||
const [tagsText, setTagsText] = useState(tags.join(", "));
|
||||
|
||||
function commitTags(text: string) {
|
||||
const next = text
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
onTagsChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="Timeout (seconds)" hint="Wall-clock cap for the whole run">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={execution.timeoutSeconds}
|
||||
onChange={(e) =>
|
||||
onExecutionChange({ timeoutSeconds: clampInt(e.target.value, 1, 600) })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Max retries" hint="Per-step retry budget">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={execution.maxRetries}
|
||||
onChange={(e) => onExecutionChange({ maxRetries: clampInt(e.target.value, 0, 2) })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Retry backoff">
|
||||
<Select
|
||||
value={execution.retryBackoff}
|
||||
onValueChange={(value) =>
|
||||
onExecutionChange({ retryBackoff: value as BuilderExecution["retryBackoff"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent matchTriggerWidth={false} className="w-auto min-w-48">
|
||||
{BACKOFF_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="If already running">
|
||||
<Select
|
||||
value={execution.concurrency}
|
||||
onValueChange={(value) =>
|
||||
onExecutionChange({ concurrency: value as BuilderExecution["concurrency"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent matchTriggerWidth={false} className="w-auto min-w-64">
|
||||
{CONCURRENCY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Tags" hint="Comma-separated. Optional.">
|
||||
<Input
|
||||
value={tagsText}
|
||||
placeholder="research, weekly"
|
||||
onChange={(e) => setTagsText(e.target.value)}
|
||||
onBlur={(e) => commitTags(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,534 +0,0 @@
|
|||
"use client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Code2, LayoutList } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
addTriggerMutationAtom,
|
||||
createAutomationMutationAtom,
|
||||
removeTriggerMutationAtom,
|
||||
updateAutomationMutationAtom,
|
||||
updateTriggerMutationAtom,
|
||||
} from "@/atoms/automations/automations-mutation.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
type Automation,
|
||||
automationCreateRequest,
|
||||
automationUpdateRequest,
|
||||
} from "@/contracts/types/automation.types";
|
||||
import { useAutomationEligibleModels } from "@/hooks/use-automation-eligible-models";
|
||||
import {
|
||||
type BuilderForm,
|
||||
type BuilderModels,
|
||||
buildCreatePayload,
|
||||
builderFormSchema,
|
||||
buildScheduleTrigger,
|
||||
buildUpdatePayload,
|
||||
createEmptyForm,
|
||||
formFromAutomation,
|
||||
type HydratableTrigger,
|
||||
hasResolvedModels,
|
||||
hydrateForm,
|
||||
} from "@/lib/automations/builder-schema";
|
||||
import { AdvancedSection } from "./advanced-section";
|
||||
import { AutomationModelFields } from "./automation-model-fields";
|
||||
import { BasicsSection } from "./basics-section";
|
||||
import { BuilderSummary } from "./builder-summary";
|
||||
import { JsonModePanel } from "./json-mode-panel";
|
||||
import { ScheduleSection } from "./schedule-section";
|
||||
import { TaskList } from "./task-list";
|
||||
import { UnattendedToggle } from "./unattended-toggle";
|
||||
|
||||
interface AutomationBuilderFormProps {
|
||||
mode: "create" | "edit";
|
||||
searchSpaceId: number;
|
||||
/** Required in edit mode; seeds the form and trigger reconciliation. */
|
||||
automation?: Automation;
|
||||
/**
|
||||
* Optional extra create-mode block reason (composed with the form's own
|
||||
* model-eligibility gate). Shown as the submit button's tooltip. Model
|
||||
* eligibility itself is now owned by the in-form pickers.
|
||||
*/
|
||||
submitDisabledReason?: string;
|
||||
renderModeSwitcher?: (modeSwitcher: ReactNode) => ReactNode;
|
||||
}
|
||||
|
||||
type Mode = "form" | "json";
|
||||
|
||||
function mapFormErrors(error: z.ZodError): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const path = issue.path;
|
||||
let key: string;
|
||||
if (path[0] === "tasks" && typeof path[1] === "number") key = `tasks.${path[1]}.query`;
|
||||
else if (path[0] === "schedule") key = "schedule";
|
||||
else key = String(path[0] ?? "_root");
|
||||
if (!out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function AutomationBuilderForm({
|
||||
mode,
|
||||
searchSpaceId,
|
||||
automation,
|
||||
submitDisabledReason,
|
||||
renderModeSwitcher,
|
||||
}: AutomationBuilderFormProps) {
|
||||
const router = useRouter();
|
||||
const { mutateAsync: createAutomation } = useAtomValue(createAutomationMutationAtom);
|
||||
const { mutateAsync: updateAutomation } = useAtomValue(updateAutomationMutationAtom);
|
||||
const { mutateAsync: addTrigger } = useAtomValue(addTriggerMutationAtom);
|
||||
const { mutateAsync: updateTrigger } = useAtomValue(updateTriggerMutationAtom);
|
||||
const { mutateAsync: removeTrigger } = useAtomValue(removeTriggerMutationAtom);
|
||||
|
||||
// Initial state: create starts empty in form mode; edit hydrates, falling
|
||||
// back to JSON mode when the definition can't be represented in the form.
|
||||
const initial = useMemo(() => {
|
||||
if (mode === "edit" && automation) {
|
||||
const result = formFromAutomation(automation);
|
||||
if (result.formable) {
|
||||
return { mode: "form" as Mode, form: result.form, notice: undefined };
|
||||
}
|
||||
return {
|
||||
mode: "json" as Mode,
|
||||
form: createEmptyForm(),
|
||||
notice: `This automation ${result.reason}, which the form can't show. Edit it as JSON below`,
|
||||
};
|
||||
}
|
||||
return { mode: "form" as Mode, form: createEmptyForm(), notice: undefined };
|
||||
}, [mode, automation]);
|
||||
|
||||
const [activeMode, setActiveMode] = useState<Mode>(initial.mode);
|
||||
const [form, setForm] = useState<BuilderForm>(initial.form);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [rootError, setRootError] = useState<string | null>(null);
|
||||
|
||||
const [jsonValue, setJsonValue] = useState<Record<string, unknown>>(() =>
|
||||
initial.mode === "json" ? jsonFromAutomation(automation) : {}
|
||||
);
|
||||
const [jsonIssues, setJsonIssues] = useState<string[]>([]);
|
||||
const [jsonNotice, setJsonNotice] = useState<string | undefined>(initial.notice);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Eligible models + the search-space-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();
|
||||
|
||||
// Resolve each slot during render: an explicit (non-zero) pick wins,
|
||||
// otherwise fall back to the eligible default. No effect copies async hook
|
||||
// data into state, so there's no flicker/loop and the user's pick is sticky.
|
||||
const resolvedModels = useMemo<BuilderModels>(
|
||||
() => ({
|
||||
chatModelId: form.models.chatModelId || eligibleModels.llm.defaultId || 0,
|
||||
imageConfigId: form.models.imageConfigId || eligibleModels.image.defaultId || 0,
|
||||
visionConfigId: form.models.visionConfigId || eligibleModels.vision.defaultId || 0,
|
||||
}),
|
||||
[
|
||||
form.models,
|
||||
eligibleModels.llm.defaultId,
|
||||
eligibleModels.image.defaultId,
|
||||
eligibleModels.vision.defaultId,
|
||||
]
|
||||
);
|
||||
|
||||
// The form with resolved models folded in — what every payload builder reads.
|
||||
const formForPayload = useMemo<BuilderForm>(
|
||||
() => ({ ...form, models: resolvedModels }),
|
||||
[form, resolvedModels]
|
||||
);
|
||||
|
||||
function patchForm(patch: Partial<BuilderForm>) {
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
}
|
||||
|
||||
function jsonFromCurrentForm(): Record<string, unknown> {
|
||||
if (mode === "edit" && automation) {
|
||||
return { ...buildUpdatePayload(formForPayload), status: automation.status };
|
||||
}
|
||||
const { search_space_id: _ignored, ...rest } = buildCreatePayload(
|
||||
formForPayload,
|
||||
searchSpaceId
|
||||
);
|
||||
return rest;
|
||||
}
|
||||
|
||||
function switchToJson() {
|
||||
setJsonValue(jsonFromCurrentForm());
|
||||
setJsonIssues([]);
|
||||
setJsonNotice(undefined);
|
||||
setActiveMode("json");
|
||||
}
|
||||
|
||||
function switchToForm() {
|
||||
const result = tryJsonToForm();
|
||||
if (result.ok) {
|
||||
setForm(result.form);
|
||||
setErrors({});
|
||||
setRootError(null);
|
||||
setActiveMode("form");
|
||||
return;
|
||||
}
|
||||
setJsonIssues(result.issues);
|
||||
setJsonNotice(result.notice);
|
||||
}
|
||||
|
||||
function tryJsonToForm():
|
||||
| { ok: true; form: BuilderForm }
|
||||
| { ok: false; issues: string[]; notice?: string } {
|
||||
// Read the raw tree defensively rather than strict-validating: an
|
||||
// incomplete JSON edit should still round-trip into the form, where the
|
||||
// form's own validation enforces completeness on submit.
|
||||
const definition = jsonValue.definition;
|
||||
if (!definition || typeof definition !== "object") {
|
||||
return { ok: false, issues: [], notice: "Add a definition before switching to the form" };
|
||||
}
|
||||
|
||||
const name =
|
||||
typeof jsonValue.name === "string"
|
||||
? jsonValue.name
|
||||
: mode === "edit" && automation
|
||||
? automation.name
|
||||
: "";
|
||||
const description = typeof jsonValue.description === "string" ? jsonValue.description : null;
|
||||
const triggers =
|
||||
mode === "edit" && automation
|
||||
? (automation.triggers ?? [])
|
||||
: extractTriggers(jsonValue.triggers);
|
||||
|
||||
const h = hydrateForm(name, description, definition, triggers);
|
||||
return h.formable
|
||||
? { ok: true, form: h.form }
|
||||
: { ok: false, issues: [], notice: `Can't show in the form: it ${h.reason}` };
|
||||
}
|
||||
|
||||
function validateForm(): Record<string, string> | null {
|
||||
const result = builderFormSchema.safeParse(form);
|
||||
const next = result.success ? {} : mapFormErrors(result.error);
|
||||
|
||||
// The schedule model fields aren't deeply validated by the schema.
|
||||
if (form.schedule?.mode === "preset") {
|
||||
const m = form.schedule.model;
|
||||
if (m.frequency === "weekly" && m.daysOfWeek.length === 0) {
|
||||
next.schedule = "Pick at least one day for the weekly schedule";
|
||||
}
|
||||
} else if (form.schedule?.mode === "cron" && !form.schedule.cron.trim()) {
|
||||
next.schedule = "Enter a schedule expression";
|
||||
}
|
||||
|
||||
return Object.keys(next).length > 0 ? next : null;
|
||||
}
|
||||
|
||||
async function reconcileTriggers(automationId: number) {
|
||||
const desired = buildScheduleTrigger(form);
|
||||
const existing = (automation?.triggers ?? [])[0];
|
||||
if (!existing && desired) {
|
||||
await addTrigger({ automationId, payload: desired });
|
||||
} else if (existing && !desired) {
|
||||
await removeTrigger({ automationId, triggerId: existing.id });
|
||||
} else if (existing && desired) {
|
||||
await updateTrigger({
|
||||
automationId,
|
||||
triggerId: existing.id,
|
||||
patch: { params: desired.params, enabled: desired.enabled },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
setRootError(null);
|
||||
const formErrors = validateForm();
|
||||
if (formErrors) {
|
||||
setErrors(formErrors);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (mode === "edit" && automation) {
|
||||
const payload = buildUpdatePayload(formForPayload);
|
||||
const parsed = automationUpdateRequest.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
setRootError(zodIssueList(parsed.error).join("; "));
|
||||
return;
|
||||
}
|
||||
await updateAutomation({ automationId: automation.id, patch: parsed.data });
|
||||
await reconcileTriggers(automation.id);
|
||||
router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`);
|
||||
} else {
|
||||
const payload = buildCreatePayload(formForPayload, searchSpaceId);
|
||||
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}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setRootError((err as Error).message ?? "Submit failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitJson() {
|
||||
setJsonIssues([]);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (mode === "edit" && automation) {
|
||||
const parsed = automationUpdateRequest.safeParse(jsonValue);
|
||||
if (!parsed.success) {
|
||||
setJsonIssues(zodIssueList(parsed.error));
|
||||
return;
|
||||
}
|
||||
await updateAutomation({ automationId: automation.id, patch: parsed.data });
|
||||
router.push(`/dashboard/${searchSpaceId}/automations/${automation.id}`);
|
||||
} else {
|
||||
const parsed = automationCreateRequest.safeParse({
|
||||
...jsonValue,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
setJsonIssues(zodIssueList(parsed.error));
|
||||
return;
|
||||
}
|
||||
const created = await createAutomation(parsed.data);
|
||||
router.push(`/dashboard/${searchSpaceId}/automations/${created.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setJsonIssues([(err as Error).message ?? "Submit failed"]);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const submitLabel = mode === "edit" ? "Save changes" : "Create automation";
|
||||
// Block creation until every model slot resolves to an eligible id. The
|
||||
// per-field Alert already explains *why* a slot is empty; this just guards
|
||||
// submit. `submitDisabledReason` (from the caller) still composes in.
|
||||
const modelsUnresolved =
|
||||
mode === "create" && !eligibleModels.isLoading && !hasResolvedModels(resolvedModels);
|
||||
const effectiveDisabledReason =
|
||||
submitDisabledReason ??
|
||||
(modelsUnresolved
|
||||
? "Set up a premium or your own (BYOK) agent, image, and vision model in role settings before creating an automation."
|
||||
: undefined);
|
||||
// Only gate creation; editing an existing automation isn't blocked here.
|
||||
const submitBlocked = mode === "create" && !!effectiveDisabledReason;
|
||||
const modeSwitcher = (
|
||||
<Tabs
|
||||
value={activeMode}
|
||||
onValueChange={(value) => {
|
||||
if (value === activeMode) return;
|
||||
if (value === "form") switchToForm();
|
||||
else if (value === "json") switchToJson();
|
||||
}}
|
||||
>
|
||||
<TabsList className="h-6 gap-0 rounded-md bg-muted/60 p-0.5 select-none">
|
||||
<TabsTrigger
|
||||
value="form"
|
||||
className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
<LayoutList className="size-3 shrink-0" />
|
||||
<span className="leading-none">Form</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="json"
|
||||
className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
<Code2 className="size-3 shrink-0" />
|
||||
<span className="leading-none">Edit as JSON</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{renderModeSwitcher ? (
|
||||
renderModeSwitcher(modeSwitcher)
|
||||
) : (
|
||||
<div className="flex items-center justify-end">{modeSwitcher}</div>
|
||||
)}
|
||||
|
||||
{activeMode === "json" ? (
|
||||
<JsonModePanel
|
||||
value={jsonValue}
|
||||
issues={jsonIssues}
|
||||
notice={jsonNotice}
|
||||
onChange={setJsonValue}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="rounded-md border-accent bg-accent/20">
|
||||
<section>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Basics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BasicsSection
|
||||
name={form.name}
|
||||
description={form.description}
|
||||
errors={errors}
|
||||
onChange={patchForm}
|
||||
/>
|
||||
</CardContent>
|
||||
</section>
|
||||
<Separator className="mx-auto data-[orientation=horizontal]:w-[calc(100%-6rem)]" />
|
||||
<section>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Tasks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<TaskList
|
||||
tasks={form.tasks}
|
||||
errors={errors}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onChange={(tasks) => patchForm({ tasks })}
|
||||
/>
|
||||
<UnattendedToggle
|
||||
checked={form.unattended}
|
||||
onChange={(unattended) => patchForm({ unattended })}
|
||||
/>
|
||||
</CardContent>
|
||||
</section>
|
||||
<Separator className="mx-auto data-[orientation=horizontal]:w-[calc(100%-6rem)]" />
|
||||
<section>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Schedule</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScheduleSection
|
||||
schedule={form.schedule}
|
||||
timezone={form.timezone}
|
||||
errors={errors}
|
||||
onScheduleChange={(schedule) => patchForm({ schedule })}
|
||||
onTimezoneChange={(timezone) => patchForm({ timezone })}
|
||||
/>
|
||||
</CardContent>
|
||||
</section>
|
||||
<Separator className="mx-auto data-[orientation=horizontal]:w-[calc(100%-6rem)]" />
|
||||
<section>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Models</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AutomationModelFields
|
||||
searchSpaceId={searchSpaceId}
|
||||
value={resolvedModels}
|
||||
onChange={(patch) => patchForm({ models: { ...form.models, ...patch } })}
|
||||
/>
|
||||
</CardContent>
|
||||
</section>
|
||||
<Separator className="mx-auto data-[orientation=horizontal]:w-[calc(100%-6rem)]" />
|
||||
<section>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AdvancedSection
|
||||
execution={form.execution}
|
||||
tags={form.tags}
|
||||
onExecutionChange={(patch) =>
|
||||
patchForm({ execution: { ...form.execution, ...patch } })
|
||||
}
|
||||
onTagsChange={(tags) => patchForm({ tags })}
|
||||
/>
|
||||
</CardContent>
|
||||
</section>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="rounded-md border-accent bg-accent/20 lg:sticky lg:top-4">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BuilderSummary form={form} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rootError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle aria-hidden />
|
||||
<AlertDescription>{rootError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{submitBlocked ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* aria-disabled keeps the button focusable so the tooltip is
|
||||
reachable by hover and keyboard; onClick is a no-op. */}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
aria-disabled
|
||||
className="cursor-not-allowed opacity-50"
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">{effectiveDisabledReason}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={submitting}
|
||||
className="relative"
|
||||
onClick={() => (activeMode === "json" ? submitJson() : submitForm())}
|
||||
>
|
||||
<span className={submitting ? "opacity-0" : ""}>{submitLabel}</span>
|
||||
{submitting && <Spinner size="xs" className="absolute" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function extractTriggers(raw: unknown): HydratableTrigger[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((entry) => {
|
||||
const obj = entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
|
||||
return {
|
||||
type: typeof obj.type === "string" ? obj.type : "",
|
||||
params:
|
||||
obj.params && typeof obj.params === "object" ? (obj.params as Record<string, unknown>) : {},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function zodIssueList(error: z.ZodError): string[] {
|
||||
return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`);
|
||||
}
|
||||
|
||||
function jsonFromAutomation(automation: Automation | undefined): Record<string, unknown> {
|
||||
if (!automation) return {};
|
||||
return {
|
||||
name: automation.name,
|
||||
description: automation.description ?? null,
|
||||
status: automation.status,
|
||||
definition: automation.definition,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { memo, useId } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
type EligibleModelKind,
|
||||
type EligibleModelOption,
|
||||
useAutomationEligibleModels,
|
||||
} from "@/hooks/use-automation-eligible-models";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { Field } from "./form-field";
|
||||
|
||||
export interface AutomationModelSelection {
|
||||
chatModelId: number;
|
||||
imageConfigId: number;
|
||||
visionConfigId: number;
|
||||
}
|
||||
|
||||
interface AutomationModelFieldsProps {
|
||||
/** Resolved (effective) ids — never `0` once defaults are seeded. */
|
||||
value: AutomationModelSelection;
|
||||
onChange: (patch: Partial<AutomationModelSelection>) => void;
|
||||
searchSpaceId: number;
|
||||
errors?: Partial<Record<keyof AutomationModelSelection, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three eligible-only model pickers (Chat / Image / Vision) for the
|
||||
* automation builder + chat approval card. Options come from
|
||||
* {@link useAutomationEligibleModels} (premium globals + BYOK only); selection
|
||||
* is validated + snapshotted onto `definition.models` at create time.
|
||||
*/
|
||||
export function AutomationModelFields({
|
||||
value,
|
||||
onChange,
|
||||
searchSpaceId,
|
||||
errors,
|
||||
}: AutomationModelFieldsProps) {
|
||||
const { llm, image, vision, isLoading } = useAutomationEligibleModels();
|
||||
const rolesHref = `/dashboard/${searchSpaceId}/search-space-settings/models`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ModelSelectField
|
||||
label="Chat model"
|
||||
kind={llm}
|
||||
value={value.chatModelId}
|
||||
isLoading={isLoading}
|
||||
rolesHref={rolesHref}
|
||||
error={errors?.chatModelId}
|
||||
onChange={(id) => onChange({ chatModelId: id })}
|
||||
/>
|
||||
<ModelSelectField
|
||||
label="Image model"
|
||||
kind={image}
|
||||
value={value.imageConfigId}
|
||||
isLoading={isLoading}
|
||||
rolesHref={rolesHref}
|
||||
error={errors?.imageConfigId}
|
||||
onChange={(id) => onChange({ imageConfigId: id })}
|
||||
/>
|
||||
<ModelSelectField
|
||||
label="Vision model"
|
||||
kind={vision}
|
||||
value={value.visionConfigId}
|
||||
isLoading={isLoading}
|
||||
rolesHref={rolesHref}
|
||||
error={errors?.visionConfigId}
|
||||
onChange={(id) => onChange({ visionConfigId: id })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelSelectFieldProps {
|
||||
label: string;
|
||||
kind: EligibleModelKind;
|
||||
value: number;
|
||||
isLoading: boolean;
|
||||
rolesHref: string;
|
||||
error?: string;
|
||||
onChange: (id: number) => void;
|
||||
}
|
||||
|
||||
const ModelSelectField = memo(function ModelSelectField({
|
||||
label,
|
||||
kind,
|
||||
value,
|
||||
isLoading,
|
||||
rolesHref,
|
||||
error,
|
||||
onChange,
|
||||
}: ModelSelectFieldProps) {
|
||||
const triggerId = useId();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind.options.length === 0) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert aria-hidden />
|
||||
<AlertTitle>No eligible models</AlertTitle>
|
||||
<AlertDescription className="block leading-5">
|
||||
Use a premium model or your own (BYOK) model in{" "}
|
||||
<Link href={rolesHref} className="font-medium underline underline-offset-2">
|
||||
role settings
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
const premium = kind.options.filter((o) => !o.isBYOK);
|
||||
const byok = kind.options.filter((o) => o.isBYOK);
|
||||
const selected = value ? kind.byId.get(value) : undefined;
|
||||
|
||||
return (
|
||||
<Field label={label} htmlFor={triggerId} error={error}>
|
||||
<Select value={value ? String(value) : undefined} onValueChange={(v) => onChange(Number(v))}>
|
||||
<SelectTrigger
|
||||
id={triggerId}
|
||||
aria-label={label}
|
||||
aria-invalid={error ? true : undefined}
|
||||
className="w-full"
|
||||
>
|
||||
{selected ? (
|
||||
<span className="flex items-center gap-2">
|
||||
{getProviderIcon(selected.provider)}
|
||||
<span className="truncate">{selected.name}</span>
|
||||
</span>
|
||||
) : (
|
||||
<SelectValue placeholder="Select a model" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent matchTriggerWidth={false} className="w-auto min-w-80 max-w-[90vw]">
|
||||
{premium.length > 0 ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Premium</SelectLabel>
|
||||
{premium.map((option) => (
|
||||
<ModelOption key={option.id} option={option} badge="Premium" />
|
||||
))}
|
||||
</SelectGroup>
|
||||
) : null}
|
||||
{premium.length > 0 && byok.length > 0 ? <SelectSeparator /> : null}
|
||||
{byok.length > 0 ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Your models</SelectLabel>
|
||||
{byok.map((option) => (
|
||||
<ModelOption key={option.id} option={option} badge="BYOK" />
|
||||
))}
|
||||
</SelectGroup>
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
);
|
||||
});
|
||||
|
||||
function ModelOption({
|
||||
option,
|
||||
badge,
|
||||
}: {
|
||||
option: EligibleModelOption;
|
||||
badge: "Premium" | "BYOK";
|
||||
}) {
|
||||
return (
|
||||
<SelectItem value={String(option.id)} description={option.modelName}>
|
||||
<span className="flex items-center gap-2">
|
||||
{getProviderIcon(option.provider)}
|
||||
<span className="truncate">{option.name}</span>
|
||||
<Badge variant={badge === "Premium" ? "secondary" : "outline"}>{badge}</Badge>
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
"use client";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Field } from "./form-field";
|
||||
|
||||
interface BasicsSectionProps {
|
||||
name: string;
|
||||
description: string | null;
|
||||
errors: Record<string, string>;
|
||||
onChange: (patch: { name?: string; description?: string | null }) => void;
|
||||
}
|
||||
|
||||
export function BasicsSection({ name, description, errors, onChange }: BasicsSectionProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Field label="Name" htmlFor="automation-name" required error={errors.name}>
|
||||
<Input
|
||||
id="automation-name"
|
||||
value={name}
|
||||
maxLength={200}
|
||||
placeholder="Weekly competitor digest"
|
||||
onChange={(e) => onChange({ name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Description"
|
||||
htmlFor="automation-description"
|
||||
hint="Optional. A short note about what this automation is for."
|
||||
error={errors.description}
|
||||
>
|
||||
<Textarea
|
||||
id="automation-description"
|
||||
value={description ?? ""}
|
||||
rows={2}
|
||||
placeholder="Summarize what changed and email me the highlights."
|
||||
onChange={(e) => onChange({ description: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
"use client";
|
||||
import { Dot } from "lucide-react";
|
||||
import { type BuilderForm, scheduleToCron } from "@/lib/automations/builder-schema";
|
||||
import { describeCron } from "@/lib/automations/describe-cron";
|
||||
|
||||
interface BuilderSummaryProps {
|
||||
form: BuilderForm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live, read-only mirror of what will be created. Mirrors the layout of the
|
||||
* chat ``AutomationDraftPreview`` so the two creation paths feel consistent.
|
||||
*/
|
||||
export function BuilderSummary({ form }: BuilderSummaryProps) {
|
||||
const automationName = form.name.trim() || "Untitled automation";
|
||||
const scheduleDescription = form.schedule ? describeCron(scheduleToCron(form.schedule)) : null;
|
||||
const taskCountLabel = `${form.tasks.length} task${form.tasks.length === 1 ? "" : "s"}`;
|
||||
const visibleTasks = form.tasks.slice(0, 2);
|
||||
const hiddenTaskCount = form.tasks.length - visibleTasks.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="truncate text-sm font-semibold text-muted-foreground" title={automationName}>
|
||||
{automationName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<SummaryRow label="Schedule">
|
||||
{scheduleDescription ? (
|
||||
<span className="flex flex-wrap items-center gap-x-1 gap-y-0.5">
|
||||
<span>{scheduleDescription}</span>
|
||||
<Dot className="size-4 text-muted-foreground" aria-hidden />
|
||||
<span>{form.timezone}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>No schedule — won't run automatically</span>
|
||||
)}
|
||||
</SummaryRow>
|
||||
|
||||
<SummaryRow label={taskCountLabel}>
|
||||
<ol className="ml-4 space-y-1">
|
||||
{visibleTasks.map((task, index) => (
|
||||
<li key={task.id} className="flex gap-2">
|
||||
<span className="shrink-0 text-muted-foreground">{index + 1}.</span>
|
||||
<span className="line-clamp-1 min-w-0">
|
||||
{task.query.trim() || "No instructions yet"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{hiddenTaskCount > 0 && (
|
||||
<li className="text-muted-foreground">+{hiddenTaskCount} more tasks</li>
|
||||
)}
|
||||
</ol>
|
||||
</SummaryRow>
|
||||
|
||||
<SummaryRow label="Approvals">
|
||||
{form.unattended ? "Runs without approval prompts" : "Approval prompts are rejected"}
|
||||
</SummaryRow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<div className="font-medium text-muted-foreground">{label}</div>
|
||||
<div className="text-foreground">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
"use client";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FieldProps {
|
||||
label?: string;
|
||||
htmlFor?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label + control + (hint | inline error) stack shared by every builder
|
||||
* section. Keeps spacing and error styling consistent so individual sections
|
||||
* stay focused on their inputs.
|
||||
*/
|
||||
export function Field({ label, htmlFor, hint, error, required, className, children }: FieldProps) {
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
{label && (
|
||||
<Label htmlFor={htmlFor} className="text-xs font-medium text-foreground">
|
||||
{label}
|
||||
{required && <span className="text-muted-foreground">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
{children}
|
||||
{error ? (
|
||||
<p className="flex items-center gap-1 text-xs text-destructive">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" aria-hidden />
|
||||
{error}
|
||||
</p>
|
||||
) : hint ? (
|
||||
<p className="text-xs text-muted-foreground">{hint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
"use client";
|
||||
import { AlertCircle, TriangleAlert } from "lucide-react";
|
||||
import { JsonView } from "@/components/json-view";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
||||
interface JsonModePanelProps {
|
||||
value: Record<string, unknown>;
|
||||
issues: string[];
|
||||
notice?: string;
|
||||
onChange: (next: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw-JSON escape hatch. Edits the same payload the form produces; the
|
||||
* orchestrator validates it against the contract schema on submit. Shown when
|
||||
* the user opts into "Edit as JSON" or when an existing definition uses
|
||||
* features the form can't represent.
|
||||
*/
|
||||
export function JsonModePanel({ value, issues, notice, onChange }: JsonModePanelProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{notice && (
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert aria-hidden />
|
||||
<AlertDescription>{notice}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<JsonView
|
||||
src={value}
|
||||
editable
|
||||
onChange={(next) => onChange(next as Record<string, unknown>)}
|
||||
collapsed={false}
|
||||
className="max-h-144 overflow-auto rounded-md border border-accent bg-accent/20"
|
||||
/>
|
||||
|
||||
{issues.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle aria-hidden />
|
||||
<AlertTitle>{issues.length === 1 ? "1 issue" : `${issues.length} issues`}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-inside list-disc">
|
||||
{issues.map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import {
|
||||
InlineMentionEditor,
|
||||
type InlineMentionEditorRef,
|
||||
type MentionChipInput,
|
||||
type MentionedDocument,
|
||||
type SuggestionAnchorRect,
|
||||
type SuggestionTriggerInfo,
|
||||
} from "@/components/assistant-ui/inline-mention-editor";
|
||||
import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer-suggestion-popup";
|
||||
import {
|
||||
DocumentMentionPicker,
|
||||
type DocumentMentionPickerRef,
|
||||
} from "@/components/new-chat/document-mention-picker";
|
||||
import { Popover, PopoverAnchor } from "@/components/ui/popover";
|
||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MentionTaskInputProps {
|
||||
searchSpaceId: number;
|
||||
value: string;
|
||||
mentions: MentionedDocumentInfo[];
|
||||
onChange: (text: string, mentions: MentionedDocumentInfo[]) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
type AnchorPoint = { left: number; top: number };
|
||||
|
||||
// Mirror of thread.tsx's getComposerSuggestionAnchorPoint -- kept local so the
|
||||
// chat composer stays untouched.
|
||||
function getAnchorPoint(rect: SuggestionAnchorRect | null): AnchorPoint | null {
|
||||
if (!rect) return null;
|
||||
return { left: rect.left, top: rect.bottom };
|
||||
}
|
||||
|
||||
/** Project the editor's chip shape into the canonical mention info union. */
|
||||
function toMentionInfo(doc: MentionedDocument): MentionedDocumentInfo {
|
||||
if (doc.kind === "connector") {
|
||||
return {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
kind: "connector",
|
||||
connector_type: doc.connector_type ?? "UNKNOWN",
|
||||
account_name: doc.account_name ?? doc.title,
|
||||
};
|
||||
}
|
||||
if (doc.kind === "folder") {
|
||||
return { id: doc.id, title: doc.title, kind: "folder" };
|
||||
}
|
||||
return {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
document_type: doc.document_type ?? "UNKNOWN",
|
||||
kind: "doc",
|
||||
};
|
||||
}
|
||||
|
||||
/** Project a mention info into the editor's chip-insertion shape. */
|
||||
function toChipInput(mention: MentionedDocumentInfo): MentionChipInput {
|
||||
if (mention.kind === "connector") {
|
||||
return {
|
||||
id: mention.id,
|
||||
title: mention.title,
|
||||
kind: "connector",
|
||||
connector_type: mention.connector_type,
|
||||
account_name: mention.account_name,
|
||||
};
|
||||
}
|
||||
if (mention.kind === "folder") {
|
||||
return { id: mention.id, title: mention.title, kind: "folder" };
|
||||
}
|
||||
return {
|
||||
id: mention.id,
|
||||
title: mention.title,
|
||||
kind: "doc",
|
||||
document_type: mention.document_type,
|
||||
};
|
||||
}
|
||||
|
||||
function removeFirstToken(text: string, token: string): string {
|
||||
const index = text.indexOf(token);
|
||||
if (index === -1) return text;
|
||||
return text.slice(0, index) + text.slice(index + token.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Task input that reuses the chat ``@`` mention experience -- the same
|
||||
* ``InlineMentionEditor`` + ``DocumentMentionPicker`` as the composer. The
|
||||
* editor is the source of truth while mounted; ``onChange`` reports both the
|
||||
* plain text (chips rendered as ``@Title``) and the structured mention list
|
||||
* so the builder can persist IDs for the run.
|
||||
*/
|
||||
export function MentionTaskInput({
|
||||
searchSpaceId,
|
||||
value,
|
||||
mentions,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
}: MentionTaskInputProps) {
|
||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||
const pickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState("");
|
||||
const [anchorPoint, setAnchorPoint] = useState<AnchorPoint | null>(null);
|
||||
|
||||
// One-shot hydration of existing mentions into real chips. ``initialText``
|
||||
// seeds the literal ``@Title`` text; here we strip those tokens and
|
||||
// re-insert them as chips so the editor reports the structured docs (and
|
||||
// editing can't silently drop the mention IDs). Position isn't preserved
|
||||
// on re-hydration -- chips append after the remaining prose.
|
||||
const didHydrateRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (didHydrateRef.current) return;
|
||||
didHydrateRef.current = true;
|
||||
if (mentions.length === 0) return;
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
let baseText = value;
|
||||
for (const mention of mentions) {
|
||||
baseText = removeFirstToken(baseText, `@${mention.title}`);
|
||||
}
|
||||
baseText = baseText.replace(/[ \t]{2,}/g, " ").trim();
|
||||
editor.setText(baseText);
|
||||
for (const mention of mentions) {
|
||||
editor.insertMentionChip(toChipInput(mention), { removeTriggerText: false });
|
||||
}
|
||||
}, [mentions, value]);
|
||||
|
||||
const closePopover = useCallback(() => {
|
||||
setShowPopover(false);
|
||||
setMentionQuery("");
|
||||
setAnchorPoint(null);
|
||||
}, []);
|
||||
|
||||
const handleEditorChange = useCallback(
|
||||
(text: string, docs: MentionedDocument[]) => {
|
||||
onChange(text, docs.map(toMentionInfo));
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleMentionTrigger = useCallback((trigger: SuggestionTriggerInfo) => {
|
||||
const point = getAnchorPoint(trigger.anchorRect);
|
||||
if (!point) {
|
||||
setShowPopover(false);
|
||||
setMentionQuery("");
|
||||
setAnchorPoint(null);
|
||||
return;
|
||||
}
|
||||
setAnchorPoint((current) => current ?? point);
|
||||
setShowPopover(true);
|
||||
setMentionQuery(trigger.query);
|
||||
}, []);
|
||||
|
||||
const handleMentionClose = useCallback(() => {
|
||||
setShowPopover((open) => {
|
||||
if (open) {
|
||||
setMentionQuery("");
|
||||
setAnchorPoint(null);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePopoverOpenChange = useCallback((open: boolean) => {
|
||||
setShowPopover(open);
|
||||
if (!open) {
|
||||
setMentionQuery("");
|
||||
setAnchorPoint(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!showPopover) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
pickerRef.current?.moveDown();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
pickerRef.current?.moveUp();
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
pickerRef.current?.selectHighlighted();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (pickerRef.current?.goBack()) return;
|
||||
closePopover();
|
||||
}
|
||||
},
|
||||
[showPopover, closePopover]
|
||||
);
|
||||
|
||||
const handleSelection = useCallback(
|
||||
(picked: MentionedDocumentInfo[]) => {
|
||||
const editor = editorRef.current;
|
||||
const existing = new Set(
|
||||
(editor?.getMentionedDocuments() ?? []).map((doc) => getMentionDocKey(doc))
|
||||
);
|
||||
for (const mention of picked) {
|
||||
const key = getMentionDocKey(mention);
|
||||
if (existing.has(key)) continue;
|
||||
editor?.insertMentionChip(toChipInput(mention));
|
||||
existing.add(key);
|
||||
}
|
||||
closePopover();
|
||||
},
|
||||
[closePopover]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-popover-border focus-within:border-ring focus-within:ring-ring/50 dark:bg-input/30 min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-[3px]",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
>
|
||||
<Popover open={showPopover} onOpenChange={handlePopoverOpenChange}>
|
||||
{anchorPoint ? (
|
||||
<>
|
||||
<PopoverAnchor
|
||||
className="pointer-events-none fixed size-0"
|
||||
style={{ left: anchorPoint.left, top: anchorPoint.top }}
|
||||
/>
|
||||
<ComposerSuggestionPopoverContent side="bottom">
|
||||
<DocumentMentionPicker
|
||||
ref={pickerRef}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onSelectionChange={handleSelection}
|
||||
onDone={closePopover}
|
||||
initialSelectedDocuments={mentions}
|
||||
externalSearch={mentionQuery}
|
||||
/>
|
||||
</ComposerSuggestionPopoverContent>
|
||||
</>
|
||||
) : null}
|
||||
</Popover>
|
||||
<InlineMentionEditor
|
||||
ref={editorRef}
|
||||
initialText={value}
|
||||
placeholder={placeholder ?? "Type @ to reference files, folders, or connectors"}
|
||||
disabled={disabled}
|
||||
onChange={handleEditorChange}
|
||||
onMentionTrigger={handleMentionTrigger}
|
||||
onMentionClose={handleMentionClose}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,276 +0,0 @@
|
|||
"use client";
|
||||
import { CalendarClock, CalendarOff, Dot, Plus, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { type BuilderSchedule, scheduleToCron } from "@/lib/automations/builder-schema";
|
||||
import { describeCron } from "@/lib/automations/describe-cron";
|
||||
import {
|
||||
DEFAULT_SCHEDULE,
|
||||
FREQUENCY_OPTIONS,
|
||||
fromCron,
|
||||
type ScheduleFrequency,
|
||||
type ScheduleModel,
|
||||
toCron,
|
||||
WEEKDAY_OPTIONS,
|
||||
} from "@/lib/automations/schedule-builder";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Field } from "./form-field";
|
||||
import { TimezoneCombobox } from "./timezone-combobox";
|
||||
|
||||
interface ScheduleSectionProps {
|
||||
schedule: BuilderSchedule | null;
|
||||
timezone: string;
|
||||
errors: Record<string, string>;
|
||||
onScheduleChange: (schedule: BuilderSchedule | null) => void;
|
||||
onTimezoneChange: (timezone: string) => void;
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
return value.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
export function ScheduleSection({
|
||||
schedule,
|
||||
timezone,
|
||||
errors,
|
||||
onScheduleChange,
|
||||
onTimezoneChange,
|
||||
}: ScheduleSectionProps) {
|
||||
if (schedule === null) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-4 py-6 text-center">
|
||||
<CalendarOff className="mx-auto h-7 w-7 text-muted-foreground" aria-hidden />
|
||||
<p className="mt-2 text-sm text-foreground">No schedule</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
This automation won't run automatically until you add one.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => onScheduleChange({ mode: "preset", model: { ...DEFAULT_SCHEDULE } })}
|
||||
>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
Add a schedule
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cron = scheduleToCron(schedule);
|
||||
const label = describeCron(cron);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-border/60 bg-accent px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-sm min-w-0">
|
||||
<CalendarClock className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<span className="font-medium text-foreground truncate">{label}</span>
|
||||
<Dot className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<span className="text-muted-foreground shrink-0">{timezone}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Remove schedule"
|
||||
onClick={() => onScheduleChange(null)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{schedule.mode === "preset" ? (
|
||||
<PresetEditor
|
||||
model={schedule.model}
|
||||
onChange={(model) => onScheduleChange({ mode: "preset", model })}
|
||||
onSwitchToCron={() => onScheduleChange({ mode: "cron", cron: toCron(schedule.model) })}
|
||||
/>
|
||||
) : (
|
||||
<CronEditor
|
||||
cron={schedule.cron}
|
||||
error={errors.schedule}
|
||||
onChange={(value) => onScheduleChange({ mode: "cron", cron: value })}
|
||||
onSwitchToPreset={() =>
|
||||
onScheduleChange({
|
||||
mode: "preset",
|
||||
model: fromCron(schedule.cron) ?? { ...DEFAULT_SCHEDULE },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Field label="Timezone">
|
||||
<TimezoneCombobox value={timezone} onChange={onTimezoneChange} />
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PresetEditorProps {
|
||||
model: ScheduleModel;
|
||||
onChange: (model: ScheduleModel) => void;
|
||||
onSwitchToCron: () => void;
|
||||
}
|
||||
|
||||
function PresetEditor({ model, onChange, onSwitchToCron }: PresetEditorProps) {
|
||||
const weeklyNoDays = model.frequency === "weekly" && model.daysOfWeek.length === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="Frequency">
|
||||
<Select
|
||||
value={model.frequency}
|
||||
onValueChange={(value) => onChange({ ...model, frequency: value as ScheduleFrequency })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent matchTriggerWidth={false} className="w-auto min-w-64">
|
||||
{FREQUENCY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{model.frequency === "hourly" ? (
|
||||
<Field label="At minute">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={model.minute}
|
||||
onChange={(e) => onChange({ ...model, minute: clampInt(e.target.value, 0, 59) })}
|
||||
/>
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="At time">
|
||||
<Input
|
||||
type="time"
|
||||
value={`${pad(model.hour)}:${pad(model.minute)}`}
|
||||
onChange={(e) => {
|
||||
const [h, m] = e.target.value.split(":");
|
||||
onChange({
|
||||
...model,
|
||||
hour: clampInt(h, 0, 23),
|
||||
minute: clampInt(m, 0, 59),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{model.frequency === "weekly" && (
|
||||
<Field label="On days" error={weeklyNoDays ? "Pick at least one day" : undefined}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{WEEKDAY_OPTIONS.map((day) => {
|
||||
const active = model.daysOfWeek.includes(day.value);
|
||||
return (
|
||||
<button
|
||||
key={day.value}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() =>
|
||||
onChange({ ...model, daysOfWeek: toggleDay(model.daysOfWeek, day.value) })
|
||||
}
|
||||
className={cn(
|
||||
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border/60 bg-background text-muted-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{day.short}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{model.frequency === "monthly" && (
|
||||
<Field label="Day of month" hint={"1\u201331."}>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
value={model.dayOfMonth}
|
||||
onChange={(e) => onChange({ ...model, dayOfMonth: clampInt(e.target.value, 1, 31) })}
|
||||
className="w-24"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToCron}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
Advanced: enter a schedule expression
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CronEditorProps {
|
||||
cron: string;
|
||||
error?: string;
|
||||
onChange: (cron: string) => void;
|
||||
onSwitchToPreset: () => void;
|
||||
}
|
||||
|
||||
function CronEditor({ cron, error, onChange, onSwitchToPreset }: CronEditorProps) {
|
||||
const trimmed = cron.trim();
|
||||
const label = trimmed ? describeCron(trimmed) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Field
|
||||
label="Schedule expression"
|
||||
hint="Five-field cron, e.g. 0 9 * * 1-5 (minute hour day month weekday)."
|
||||
error={error}
|
||||
>
|
||||
<Input
|
||||
value={cron}
|
||||
placeholder="0 9 * * 1-5"
|
||||
className="font-mono"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{label && label !== trimmed && <p className="text-xs text-muted-foreground">Runs: {label}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToPreset}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
Use the simple picker
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clampInt(raw: string, min: number, max: number): number {
|
||||
const value = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(value)) return min;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function toggleDay(days: number[], value: number): number[] {
|
||||
return days.includes(value)
|
||||
? days.filter((day) => day !== value)
|
||||
: [...days, value].sort((a, b) => a - b);
|
||||
}
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
"use client";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Trash2 } from "lucide-react";
|
||||
import { Accordion, AccordionContent, AccordionItem } from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { BuilderTask } from "@/lib/automations/builder-schema";
|
||||
import { Field } from "./form-field";
|
||||
import { MentionTaskInput } from "./mention-task-input";
|
||||
|
||||
interface TaskItemProps {
|
||||
index: number;
|
||||
total: number;
|
||||
task: BuilderTask;
|
||||
searchSpaceId: number;
|
||||
error?: string;
|
||||
onChange: (patch: Partial<BuilderTask>) => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function parseOptionalInt(raw: string): number | null {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "") return null;
|
||||
const value = Number.parseInt(trimmed, 10);
|
||||
return Number.isNaN(value) ? null : value;
|
||||
}
|
||||
|
||||
export function TaskItem({
|
||||
index,
|
||||
total,
|
||||
task,
|
||||
searchSpaceId,
|
||||
error,
|
||||
onChange,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onRemove,
|
||||
}: TaskItemProps) {
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 bg-transparent p-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-semibold text-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
Task {index + 1}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
disabled={index === 0}
|
||||
aria-label="Move task up"
|
||||
onClick={onMoveUp}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
disabled={index === total - 1}
|
||||
aria-label="Move task down"
|
||||
onClick={onMoveDown}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
disabled={total === 1}
|
||||
aria-label="Remove task"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
error={error}
|
||||
hint="Type @ to reference files, folders, or connectors for extra context."
|
||||
>
|
||||
<MentionTaskInput
|
||||
searchSpaceId={searchSpaceId}
|
||||
value={task.query}
|
||||
mentions={task.mentions}
|
||||
placeholder="What should the agent do? e.g. Summarize new docs in @Marketing since the last run."
|
||||
onChange={(query, mentions) => onChange({ query, mentions })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="advanced" className="border-b-0">
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger className="group flex flex-1 items-center justify-between rounded-md py-1.5 text-left text-xs font-medium text-muted-foreground outline-none transition-all focus-visible:ring-[3px] focus-visible:ring-ring/50">
|
||||
Advanced
|
||||
<ChevronRight className="pointer-events-none size-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
<AccordionContent className="pb-1">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Max retries">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={task.maxRetries ?? ""}
|
||||
placeholder="2 retries"
|
||||
onChange={(e) => onChange({ maxRetries: parseOptionalInt(e.target.value) })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Timeout (seconds)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={task.timeoutSeconds ?? ""}
|
||||
placeholder="600 seconds"
|
||||
onChange={(e) => onChange({ timeoutSeconds: parseOptionalInt(e.target.value) })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
"use client";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { type BuilderTask, emptyTask } from "@/lib/automations/builder-schema";
|
||||
import { TaskItem } from "./task-item";
|
||||
|
||||
interface TaskListProps {
|
||||
tasks: BuilderTask[];
|
||||
errors: Record<string, string>;
|
||||
searchSpaceId: number;
|
||||
onChange: (tasks: BuilderTask[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
function updateAt(index: number, patch: Partial<BuilderTask>) {
|
||||
onChange(tasks.map((task, i) => (i === index ? { ...task, ...patch } : task)));
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
onChange(tasks.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function move(index: number, direction: -1 | 1) {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= tasks.length) return;
|
||||
const next = [...tasks];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{tasks.map((task, index) => (
|
||||
<TaskItem
|
||||
key={task.id}
|
||||
index={index}
|
||||
total={tasks.length}
|
||||
task={task}
|
||||
searchSpaceId={searchSpaceId}
|
||||
error={errors[`tasks.${index}.query`]}
|
||||
onChange={(patch) => updateAt(index, patch)}
|
||||
onMoveUp={() => move(index, -1)}
|
||||
onMoveDown={() => move(index, 1)}
|
||||
onRemove={() => removeAt(index)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{errors.tasks && <p className="text-xs text-destructive">{errors.tasks}</p>}
|
||||
|
||||
<Button type="button" size="sm" onClick={() => onChange([...tasks, emptyTask()])}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add task
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
"use client";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { getTimezones } from "@/lib/automations/builder-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TimezoneComboboxProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable IANA timezone picker. The full ``Intl.supportedValuesOf`` list is
|
||||
* long, so it lives behind a Command search instead of a flat Select.
|
||||
*/
|
||||
export function TimezoneCombobox({ value, onChange }: TimezoneComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const timezones = useMemo(() => getTimezones(), []);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between border-popover-border bg-transparent font-normal hover:bg-transparent"
|
||||
>
|
||||
<span className="truncate">{value || "Select timezone"}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[calc(var(--radix-popover-trigger-width)/3)] min-w-72 max-w-[90vw] overflow-hidden border border-popover-border p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command className="bg-popover">
|
||||
<CommandInput placeholder="Search timezone..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No timezone found.</CommandEmpty>
|
||||
<CommandGroup className="p-0">
|
||||
{timezones.map((tz) => (
|
||||
<CommandItem
|
||||
key={tz}
|
||||
value={tz}
|
||||
className="rounded-none px-3"
|
||||
onSelect={() => {
|
||||
onChange(tz);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", value === tz ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
{tz}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
"use client";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface UnattendedToggleProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps to ``auto_approve_all`` on every agent task. Automations run with no one
|
||||
* watching, so this defaults ON; turning it off means any approval prompt the
|
||||
* agent raises is rejected and the step can stall.
|
||||
*/
|
||||
export function UnattendedToggle({ checked, onChange }: UnattendedToggleProps) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 rounded-md bg-transparent">
|
||||
<div className="space-y-0.5 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Run without asking for approvals
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tasks run automatically without asking for confirmation
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
aria-label="Run without asking for approvals"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
"use client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { deleteAutomationMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
interface DeleteAutomationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
automationId: number;
|
||||
automationName: string;
|
||||
searchSpaceId: 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
|
||||
* on the list page so no callback is needed there).
|
||||
*/
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm + delete one automation. FK cascade on the backend wipes attached
|
||||
* triggers and runs, so we mention it explicitly. List re-fetch is handled
|
||||
* by the mutation atom's onSuccess.
|
||||
*/
|
||||
export function DeleteAutomationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
automationId,
|
||||
automationName,
|
||||
searchSpaceId,
|
||||
onDeleted,
|
||||
}: DeleteAutomationDialogProps) {
|
||||
const { mutateAsync: deleteAutomation } = useAtomValue(deleteAutomationMutationAtom);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleConfirm() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await deleteAutomation({ automationId, searchSpaceId });
|
||||
onDeleted?.();
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this automation?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<span className="font-medium text-foreground">{automationName}</span> and all of its
|
||||
triggers and run history will be removed. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={submitting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
disabled={submitting}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{submitting ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Spinner size="xs" />
|
||||
Deleting…
|
||||
</span>
|
||||
) : (
|
||||
"Delete"
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
"use client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { canPerform, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
|
||||
/**
|
||||
* Centralized RBAC gates for the automations slice. Co-located with the
|
||||
* route so adding/removing surfaces stays a one-file change. Backed by
|
||||
* the same ``myAccessAtom`` the rest of the app uses; owners short-circuit
|
||||
* to ``true`` for every action.
|
||||
*
|
||||
* Mirrors backend permissions in ``app.db.permissions`` (automations:*).
|
||||
*/
|
||||
export interface AutomationPermissions {
|
||||
loading: boolean;
|
||||
canCreate: boolean;
|
||||
canRead: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canExecute: boolean;
|
||||
}
|
||||
|
||||
export function useAutomationPermissions(): AutomationPermissions {
|
||||
const { data: access, isLoading } = useAtomValue(myAccessAtom);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
loading: isLoading,
|
||||
canCreate: canPerform(access, "automations:create"),
|
||||
canRead: canPerform(access, "automations:read"),
|
||||
canUpdate: canPerform(access, "automations:update"),
|
||||
canDelete: canPerform(access, "automations:delete"),
|
||||
canExecute: canPerform(access, "automations:execute"),
|
||||
}),
|
||||
[access, isLoading]
|
||||
);
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
"use client";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { AutomationBuilderForm } from "../components/builder/automation-builder-form";
|
||||
import { useAutomationPermissions } from "../hooks/use-automation-permissions";
|
||||
import { AutomationNewHeader } from "./components/automation-new-header";
|
||||
|
||||
interface AutomationNewContentProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrator for the create route. Gates on ``automations:create`` so users
|
||||
* who can't create don't even see the form; same panel as the detail page's
|
||||
* access-denied state for consistency. The builder defaults to the friendly
|
||||
* form with a raw-JSON escape hatch.
|
||||
*
|
||||
* Model eligibility is no longer gated here — the builder's own model pickers
|
||||
* 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) {
|
||||
const perms = useAutomationPermissions();
|
||||
|
||||
if (perms.loading) {
|
||||
return <div className="h-32 rounded-md border border-border/60 bg-muted/10 animate-pulse" />;
|
||||
}
|
||||
|
||||
if (!perms.canCreate) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-6 py-12 text-center">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AutomationBuilderForm
|
||||
mode="create"
|
||||
searchSpaceId={searchSpaceId}
|
||||
renderModeSwitcher={(modeSwitcher) => (
|
||||
<AutomationNewHeader searchSpaceId={searchSpaceId} modeSwitcher={modeSwitcher} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
"use client";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AutomationNewHeaderProps {
|
||||
searchSpaceId: number;
|
||||
modeSwitcher?: ReactNode;
|
||||
}
|
||||
|
||||
export function AutomationNewHeader({ searchSpaceId, 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`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||
Back to automations
|
||||
</Link>
|
||||
</Button>
|
||||
{modeSwitcher ? <div className="shrink-0 md:hidden">{modeSwitcher}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground">New automation</h1>
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
Configure the task, schedule, and execution settings for this automation.
|
||||
</p>
|
||||
</div>
|
||||
{modeSwitcher ? (
|
||||
<div className="ml-auto hidden shrink-0 md:block">{modeSwitcher}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { AutomationNewContent } from "./automation-new-content";
|
||||
|
||||
export default async function NewAutomationPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string }>;
|
||||
}) {
|
||||
const { search_space_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<AutomationNewContent searchSpaceId={Number(search_space_id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { AutomationsContent } from "./automations-content";
|
||||
|
||||
export default async function AutomationsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string }>;
|
||||
}) {
|
||||
const { search_space_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl space-y-6">
|
||||
<AutomationsContent searchSpaceId={Number(search_space_id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { AutoReloadSettings } from "@/components/settings/auto-reload-settings";
|
||||
import { BuyCreditsContent } from "@/components/settings/buy-credits-content";
|
||||
|
||||
export default function BuyMorePage() {
|
||||
return (
|
||||
<div className="flex min-h-[37rem] w-full select-none items-center justify-center py-8">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<BuyCreditsContent />
|
||||
<AutoReloadSettings />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function BuyPagesPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params?.search_space_id ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/dashboard/${searchSpaceId}/buy-more`);
|
||||
}, [router, searchSpaceId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function BuyTokensPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params?.search_space_id ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/dashboard/${searchSpaceId}/buy-more`);
|
||||
}, [router, searchSpaceId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { OAUTH_RESULT_COOKIE, type OAuthCallbackResult } from "@/contracts/types/oauth.types";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ search_space_id: string }> }
|
||||
) {
|
||||
const { search_space_id } = await params;
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
|
||||
const payload: OAuthCallbackResult = {
|
||||
success: searchParams.get("success"),
|
||||
error: searchParams.get("error"),
|
||||
connector: searchParams.get("connector"),
|
||||
connectorId: searchParams.get("connectorId"),
|
||||
};
|
||||
const result = JSON.stringify(payload);
|
||||
|
||||
const response = new NextResponse(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `/dashboard/${search_space_id}/new-chat`,
|
||||
},
|
||||
});
|
||||
response.cookies.set(OAUTH_RESULT_COOKIE, result, {
|
||||
path: "/",
|
||||
maxAge: 60,
|
||||
httpOnly: false,
|
||||
sameSite: "lax",
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { EarnCreditsContent } from "@/components/settings/earn-credits-content";
|
||||
|
||||
export default function EarnCreditsPage() {
|
||||
return (
|
||||
<div className="w-full select-none space-y-6">
|
||||
<EarnCreditsContent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,100 +0,0 @@
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="w-full px-6 py-4 space-y-6 min-h-[calc(100vh-64px)] animate-in fade-in duration-300">
|
||||
{/* Summary Dashboard Skeleton */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="rounded-lg border p-4">
|
||||
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Header Section Skeleton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-24" />
|
||||
</div>
|
||||
|
||||
{/* Filters Skeleton */}
|
||||
<div className="flex flex-wrap items-center justify-start gap-3 w-full">
|
||||
<div className="flex items-center gap-3 flex-wrap w-full sm:w-auto">
|
||||
<Skeleton className="h-9 w-full sm:w-60" />
|
||||
<Skeleton className="h-9 w-24" />
|
||||
<Skeleton className="h-9 w-24" />
|
||||
<Skeleton className="h-9 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Skeleton */}
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
{/* Table Header */}
|
||||
<div className="border-b bg-muted/50 px-4 py-3 flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
</div>
|
||||
|
||||
{/* Table Rows */}
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-b px-4 py-3 flex items-center gap-4 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-6 w-12 rounded-full" />
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination Skeleton */}
|
||||
<div className="flex items-center justify-between gap-8 mt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-4 w-20 max-sm:sr-only" />
|
||||
<Skeleton className="h-9 w-16" />
|
||||
</div>
|
||||
|
||||
<div className="flex grow justify-end">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-9 w-9" />
|
||||
<Skeleton className="h-9 w-9" />
|
||||
<Skeleton className="h-9 w-9" />
|
||||
<Skeleton className="h-9 w-9" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// Legacy route kept as a redirect: older "insufficient credits" notifications
|
||||
// and bookmarks may still point at /more-pages.
|
||||
export default function MorePagesPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params?.search_space_id ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/dashboard/${searchSpaceId}/earn-credits`);
|
||||
}, [router, searchSpaceId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { CircleSlash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export default function PurchaseCancelPage() {
|
||||
const params = useParams();
|
||||
const searchSpaceId = String(params.search_space_id ?? "");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader className="text-center">
|
||||
<CircleSlash2 className="mx-auto h-10 w-10 text-muted-foreground" />
|
||||
<CardTitle className="text-2xl">Checkout canceled</CardTitle>
|
||||
<CardDescription>No charge was made and your account is unchanged.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center text-sm text-muted-foreground">
|
||||
You can return to the pricing options and try again whenever you're ready.
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button asChild className="w-full">
|
||||
<Link href={`/dashboard/${searchSpaceId}/buy-more`}>Back to Pricing</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>Back to Dashboard</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type { FinalizeCheckoutResponse } from "@/contracts/types/stripe.types";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
|
||||
type FinalizeState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "completed"; data: FinalizeCheckoutResponse }
|
||||
| { kind: "pending"; data: FinalizeCheckoutResponse }
|
||||
| { kind: "still_pending"; data: FinalizeCheckoutResponse }
|
||||
| { kind: "failed"; data: FinalizeCheckoutResponse }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "no_session" };
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_POLL_ATTEMPTS = 15; // ~30s total before falling back to the still_pending state
|
||||
|
||||
export default function PurchaseSuccessPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const searchSpaceId = String(params.search_space_id ?? "");
|
||||
const sessionId = searchParams.get("session_id");
|
||||
|
||||
const [state, setState] = useState<FinalizeState>(
|
||||
sessionId ? { kind: "loading" } : { kind: "no_session" }
|
||||
);
|
||||
// Tracks active polling so component unmount cancels it
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
cancelledRef.current = false;
|
||||
|
||||
const poll = async (attempt: number): Promise<void> => {
|
||||
if (cancelledRef.current) return;
|
||||
try {
|
||||
const data = await stripeApiService.finalizeCheckout(sessionId);
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
if (data.status === "completed") {
|
||||
setState({ kind: "completed", data });
|
||||
return;
|
||||
}
|
||||
if (data.status === "failed") {
|
||||
setState({ kind: "failed", data });
|
||||
return;
|
||||
}
|
||||
|
||||
// Status is "pending" - either the user paid via async
|
||||
// payment method (Klarna, ACH) or webhook + finalize both
|
||||
// raced and lost. Keep polling up to MAX_POLL_ATTEMPTS,
|
||||
// then fall back to a friendlier message that explains
|
||||
// fulfilment may complete asynchronously.
|
||||
if (attempt < MAX_POLL_ATTEMPTS) {
|
||||
setState({ kind: "pending", data });
|
||||
setTimeout(() => poll(attempt + 1), POLL_INTERVAL_MS);
|
||||
} else {
|
||||
setState({ kind: "still_pending", data });
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelledRef.current) return;
|
||||
const message = err instanceof Error ? err.message : "Unable to finalize checkout.";
|
||||
setState({ kind: "error", message });
|
||||
}
|
||||
};
|
||||
|
||||
void poll(1);
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader className="text-center">
|
||||
{state.kind === "loading" || state.kind === "pending" ? (
|
||||
<Loader2 className="mx-auto h-10 w-10 animate-spin text-primary" />
|
||||
) : state.kind === "completed" ? (
|
||||
<CheckCircle2 className="mx-auto h-10 w-10 text-emerald-500" />
|
||||
) : (
|
||||
<AlertCircle className="mx-auto h-10 w-10 text-amber-500" />
|
||||
)}
|
||||
<CardTitle className="text-2xl">
|
||||
{state.kind === "loading" && "Confirming payment…"}
|
||||
{state.kind === "pending" && "Processing your payment…"}
|
||||
{state.kind === "still_pending" && "Payment still processing"}
|
||||
{state.kind === "completed" && "Purchase complete"}
|
||||
{state.kind === "failed" && "Purchase failed"}
|
||||
{state.kind === "error" && "Couldn't confirm payment"}
|
||||
{state.kind === "no_session" && "Purchase complete"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{state.kind === "loading" && "We're verifying your payment with Stripe."}
|
||||
{state.kind === "pending" &&
|
||||
"Your bank is taking a moment to confirm. This usually takes 5–30 seconds."}
|
||||
{state.kind === "still_pending" &&
|
||||
"Your payment is still being processed by your bank. We'll apply your purchase as soon as it clears — usually within a few minutes. You can safely close this page."}
|
||||
{state.kind === "completed" &&
|
||||
`Added ${formatCredit(state.data.credit_micros_granted ?? 0)} of credit to your account.`}
|
||||
{state.kind === "failed" &&
|
||||
"Stripe reported the checkout as failed or expired. Your card was not charged."}
|
||||
{state.kind === "error" &&
|
||||
"Don't worry — if your card was charged, your purchase will still apply within a minute or two."}
|
||||
{state.kind === "no_session" && "Your purchase is being applied to your account."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-center">
|
||||
{state.kind === "completed" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
New credit balance: {formatCredit(state.data.credit_micros_balance ?? 0)}
|
||||
</p>
|
||||
)}
|
||||
{state.kind === "error" && (
|
||||
<p className="text-sm text-muted-foreground">{state.message}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
<Button asChild className="w-full">
|
||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>Back to Dashboard</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href={`/dashboard/${searchSpaceId}/buy-more`}>Buy credits</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCredit(micros: number): string {
|
||||
const dollars = micros / 1_000_000;
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 2,
|
||||
}).format(dollars);
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
||||
const { search_space_id } = await params;
|
||||
return <GeneralSettingsManager searchSpaceId={Number(search_space_id)} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { ModelConnectionsSettings } from "@/components/settings/model-connections-settings";
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
||||
const { search_space_id } = await params;
|
||||
return <ModelConnectionsSettings searchSpaceId={Number(search_space_id)} />;
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
|
|
@ -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)} />;
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
||||
const { search_space_id } = await params;
|
||||
return <PublicChatSnapshotsManager searchSpaceId={Number(search_space_id)} />;
|
||||
}
|
||||
|
|
@ -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)} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,967 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Calendar,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronFirst,
|
||||
ChevronLast,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Copy,
|
||||
Hash,
|
||||
Link2,
|
||||
ShieldUser,
|
||||
Trash2,
|
||||
User,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createInviteMutationAtom,
|
||||
deleteInviteMutationAtom,
|
||||
} from "@/atoms/invites/invites-mutation.atoms";
|
||||
import {
|
||||
deleteMemberMutationAtom,
|
||||
updateMemberMutationAtom,
|
||||
} from "@/atoms/members/members-mutation.atoms";
|
||||
import { canPerform, membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type {
|
||||
CreateInviteRequest,
|
||||
DeleteInviteRequest,
|
||||
Invite,
|
||||
} from "@/contracts/types/invites.types";
|
||||
import type {
|
||||
DeleteMembershipRequest,
|
||||
Membership,
|
||||
UpdateMembershipRequest,
|
||||
} from "@/contracts/types/members.types";
|
||||
import type { Role } from "@/contracts/types/roles.types";
|
||||
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function getAvatarInitials(member: Membership): string {
|
||||
if (member.user_display_name) {
|
||||
const parts = member.user_display_name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return member.user_display_name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
if (member.user_email) {
|
||||
const emailName = member.user_email.split("@")[0];
|
||||
return emailName.slice(0, 2).toUpperCase();
|
||||
}
|
||||
return "U";
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
const SKELETON_KEYS = Array.from({ length: PAGE_SIZE }, (_, i) => `skeleton-${i}`);
|
||||
|
||||
interface TeamContentProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
||||
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
|
||||
|
||||
const hasPermission = useCallback(
|
||||
(permission: string) => canPerform(access, permission),
|
||||
[access]
|
||||
);
|
||||
const { data: members = [], isLoading: membersLoading } = useAtomValue(membersAtom);
|
||||
|
||||
const { mutateAsync: updateMember } = useAtomValue(updateMemberMutationAtom);
|
||||
const { mutateAsync: deleteMember } = useAtomValue(deleteMemberMutationAtom);
|
||||
const { mutateAsync: createInvite } = useAtomValue(createInviteMutationAtom);
|
||||
const { mutateAsync: revokeInvite } = useAtomValue(deleteInviteMutationAtom);
|
||||
|
||||
const handleRevokeInvite = useCallback(
|
||||
async (inviteId: number): Promise<boolean> => {
|
||||
const request: DeleteInviteRequest = {
|
||||
search_space_id: searchSpaceId,
|
||||
invite_id: inviteId,
|
||||
};
|
||||
await revokeInvite(request);
|
||||
return true;
|
||||
},
|
||||
[revokeInvite, searchSpaceId]
|
||||
);
|
||||
|
||||
const handleCreateInvite = useCallback(
|
||||
async (inviteData: CreateInviteRequest["data"]) => {
|
||||
const request: CreateInviteRequest = {
|
||||
search_space_id: searchSpaceId,
|
||||
data: inviteData,
|
||||
};
|
||||
return await createInvite(request);
|
||||
},
|
||||
[createInvite, searchSpaceId]
|
||||
);
|
||||
|
||||
const handleUpdateMember = useCallback(
|
||||
async (membershipId: number, roleId: number | null): Promise<Membership> => {
|
||||
const request: UpdateMembershipRequest = {
|
||||
search_space_id: searchSpaceId,
|
||||
membership_id: membershipId,
|
||||
data: { role_id: roleId },
|
||||
};
|
||||
return (await updateMember(request)) as Membership;
|
||||
},
|
||||
[updateMember, searchSpaceId]
|
||||
);
|
||||
|
||||
const handleRemoveMember = useCallback(
|
||||
async (membershipId: number) => {
|
||||
const request: DeleteMembershipRequest = {
|
||||
search_space_id: searchSpaceId,
|
||||
membership_id: membershipId,
|
||||
};
|
||||
await deleteMember(request);
|
||||
return true;
|
||||
},
|
||||
[deleteMember, searchSpaceId]
|
||||
);
|
||||
|
||||
const { data: roles = [], isLoading: rolesLoading } = useQuery({
|
||||
queryKey: cacheKeys.roles.all(searchSpaceId.toString()),
|
||||
queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }),
|
||||
enabled: !!searchSpaceId,
|
||||
});
|
||||
|
||||
const { data: invites = [], isLoading: invitesLoading } = useQuery({
|
||||
queryKey: cacheKeys.invites.all(searchSpaceId.toString()),
|
||||
queryFn: () => invitesApiService.getInvites({ search_space_id: searchSpaceId }),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const activeInvites = useMemo(() => invites.filter((i) => i.is_active), [invites]);
|
||||
|
||||
const canInvite = hasPermission("members:invite");
|
||||
const canManageRoles = hasPermission("members:manage_roles");
|
||||
const canRemove = hasPermission("members:remove");
|
||||
|
||||
const owners = useMemo(() => members.filter((m) => m.is_owner), [members]);
|
||||
const nonOwnerMembers = useMemo(() => members.filter((m) => !m.is_owner), [members]);
|
||||
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const totalItems = nonOwnerMembers.length;
|
||||
const lastPage = Math.max(0, Math.ceil(totalItems / PAGE_SIZE) - 1);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageIndex > lastPage) setPageIndex(lastPage);
|
||||
}, [pageIndex, lastPage]);
|
||||
|
||||
const paginatedMembers = useMemo(() => {
|
||||
const start = pageIndex * PAGE_SIZE;
|
||||
const end = start + PAGE_SIZE;
|
||||
return nonOwnerMembers.slice(
|
||||
Math.min(start, nonOwnerMembers.length),
|
||||
Math.min(end, nonOwnerMembers.length)
|
||||
);
|
||||
}, [nonOwnerMembers, pageIndex]);
|
||||
|
||||
const displayStart = totalItems > 0 ? pageIndex * PAGE_SIZE + 1 : 0;
|
||||
const displayEnd = Math.min((pageIndex + 1) * PAGE_SIZE, totalItems);
|
||||
const canPrev = pageIndex > 0;
|
||||
const canNext = displayEnd < totalItems;
|
||||
|
||||
useEffect(() => {
|
||||
if (members.length > 0 && !membersLoading) {
|
||||
const ownerCount = members.filter((m) => m.is_owner).length;
|
||||
trackSearchSpaceUsersViewed(searchSpaceId, members.length, ownerCount);
|
||||
}
|
||||
}, [members, membersLoading, searchSpaceId]);
|
||||
|
||||
if (accessLoading || membersLoading) {
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground">Members</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm bg-black text-white dark:bg-white dark:text-black"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
Invite members
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none gap-1.5 md:gap-2 rounded-md bg-muted px-3 text-xs md:text-sm hover:bg-accent"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 md:h-4 md:w-4 rotate-315" />
|
||||
Active invites
|
||||
<span className="inline-flex items-center justify-center h-4 md:h-5 min-w-4 md:min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200">
|
||||
<Skeleton className="h-2.5 w-2.5 rounded-sm bg-neutral-500/60" />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-accent overflow-hidden">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-b border-border/60">
|
||||
<TableHead className="w-[45%] px-4 md:px-6 border-r border-border/60">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<User size={14} className="opacity-60 text-muted-foreground" />
|
||||
Name
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell w-[25%] border-r border-border/60">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<Clock size={14} className="opacity-60 text-muted-foreground" />
|
||||
Last logged in
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-[30%] px-4 md:px-6">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70 justify-end">
|
||||
<ShieldUser size={14} className="opacity-60 text-muted-foreground" />
|
||||
Role
|
||||
</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SKELETON_KEYS.slice(0, 2).map((id) => (
|
||||
<TableRow key={id} className="border-b border-border/60 hover:bg-transparent">
|
||||
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 border-r border-border/60">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full shrink-0" />
|
||||
<Skeleton className="h-4 w-28 md:w-32" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell w-[25%] py-2.5 border-r border-border/60">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
<TableCell className="w-[30%] py-2.5 px-4 md:px-6">
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground">Members</h1>
|
||||
<p className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{members.length} {members.length === 1 ? "member" : "members"}
|
||||
</p>
|
||||
</div>
|
||||
{canInvite && (
|
||||
<div className="flex items-center gap-2">
|
||||
{rolesLoading ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm bg-black text-white dark:bg-white dark:text-black"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
Invite members
|
||||
</Button>
|
||||
) : (
|
||||
<CreateInviteDialog
|
||||
roles={roles}
|
||||
onCreateInvite={handleCreateInvite}
|
||||
searchSpaceId={searchSpaceId}
|
||||
/>
|
||||
)}
|
||||
{invitesLoading ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none gap-1.5 md:gap-2 rounded-md bg-muted px-3 text-xs md:text-sm hover:bg-accent"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 md:h-4 md:w-4 rotate-315" />
|
||||
Active invites
|
||||
<span className="inline-flex items-center justify-center h-4 md:h-5 min-w-4 md:min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200">
|
||||
<Skeleton className="h-2.5 w-2.5 rounded-sm bg-neutral-500/60" />
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-accent overflow-hidden">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent border-b border-border/60">
|
||||
<TableHead className="w-[45%] px-4 md:px-6 border-r border-border/60">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<User size={14} className="opacity-60 text-muted-foreground" />
|
||||
Name
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell w-[25%] border-r border-border/60">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<Clock size={14} className="opacity-60 text-muted-foreground" />
|
||||
Last logged in
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-[30%] px-4 md:px-6">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70 justify-end">
|
||||
<ShieldUser size={14} className="opacity-60 text-muted-foreground" />
|
||||
Role
|
||||
</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{owners.map((member) => (
|
||||
<MemberRow
|
||||
key={`member-${member.id}`}
|
||||
searchSpaceId={searchSpaceId}
|
||||
member={member}
|
||||
roles={roles}
|
||||
canManageRoles={canManageRoles}
|
||||
canRemove={canRemove}
|
||||
onUpdateRole={handleUpdateMember}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
/>
|
||||
))}
|
||||
{paginatedMembers.map((member) => (
|
||||
<MemberRow
|
||||
key={`member-${member.id}`}
|
||||
searchSpaceId={searchSpaceId}
|
||||
member={member}
|
||||
roles={roles}
|
||||
canManageRoles={canManageRoles}
|
||||
canRemove={canRemove}
|
||||
onUpdateRole={handleUpdateMember}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
/>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Users className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground">No members yet</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{totalItems > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-end gap-3 py-3 px-2">
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{displayStart}-{displayEnd} of {totalItems}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 disabled:opacity-40"
|
||||
onClick={() => setPageIndex(0)}
|
||||
disabled={!canPrev}
|
||||
aria-label="Go to first page"
|
||||
>
|
||||
<ChevronFirst size={18} strokeWidth={2} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 disabled:opacity-40"
|
||||
onClick={() => setPageIndex((i) => Math.max(0, i - 1))}
|
||||
disabled={!canPrev}
|
||||
aria-label="Go to previous page"
|
||||
>
|
||||
<ChevronLeft size={18} strokeWidth={2} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 disabled:opacity-40"
|
||||
onClick={() => setPageIndex((i) => (canNext ? i + 1 : i))}
|
||||
disabled={!canNext}
|
||||
aria-label="Go to next page"
|
||||
>
|
||||
<ChevronRight size={18} strokeWidth={2} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 disabled:opacity-40"
|
||||
onClick={() => setPageIndex(lastPage)}
|
||||
disabled={!canNext}
|
||||
aria-label="Go to last page"
|
||||
>
|
||||
<ChevronLast size={18} strokeWidth={2} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberRow({
|
||||
searchSpaceId,
|
||||
member,
|
||||
roles,
|
||||
canManageRoles,
|
||||
canRemove,
|
||||
onUpdateRole,
|
||||
onRemoveMember,
|
||||
}: {
|
||||
searchSpaceId: number;
|
||||
member: Membership;
|
||||
roles: Role[];
|
||||
canManageRoles: boolean;
|
||||
canRemove: boolean;
|
||||
onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Membership>;
|
||||
onRemoveMember: (membershipId: number) => Promise<boolean>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const initials = getAvatarInitials(member);
|
||||
const displayName = member.user_display_name || member.user_email || "Unknown";
|
||||
const roleName = member.is_owner ? "Owner" : member.role?.name || "No role";
|
||||
const showActions = !member.is_owner && (canManageRoles || canRemove);
|
||||
|
||||
return (
|
||||
<TableRow className="border-b border-border/60 transition-colors hover:bg-accent hover:text-accent-foreground">
|
||||
<TableCell className="w-[45%] py-2.5 px-4 md:px-6 max-w-0 border-r border-border/60">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
{member.user_avatar_url && (
|
||||
<AvatarImage src={member.user_avatar_url} alt={displayName} />
|
||||
)}
|
||||
<AvatarFallback className="bg-popover text-sm text-popover-foreground">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-sm truncate select-text">{displayName}</p>
|
||||
{member.user_display_name && member.user_email && (
|
||||
<p className="text-xs text-muted-foreground truncate select-text">
|
||||
{member.user_email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="hidden md:table-cell w-[25%] py-2.5 text-sm text-foreground border-r border-border/60">
|
||||
{member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-[30%] text-right py-2.5 px-4 md:px-6">
|
||||
{showActions ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto w-[74px] justify-end gap-1.5 px-0 py-0 text-sm text-muted-foreground hover:bg-transparent hover:text-accent-foreground has-[>svg]:px-0"
|
||||
>
|
||||
{roleName}
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{canManageRoles &&
|
||||
roles
|
||||
.filter((r) => r.name !== "Owner")
|
||||
.map((role) => (
|
||||
<DropdownMenuItem
|
||||
key={role.id}
|
||||
onClick={() => onUpdateRole(member.id, role.id)}
|
||||
>
|
||||
Make {role.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{canRemove && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove member?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove <span className="font-medium">{member.user_email}</span>{" "}
|
||||
from this search space. They will lose access to all resources.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onRemoveMember(member.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
<DropdownMenuSeparator className="bg-popover-border" />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
router.push(`/dashboard/${searchSpaceId}/search-space-settings/team-roles`)
|
||||
}
|
||||
>
|
||||
Manage Roles
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<span className="text-sm text-foreground">{roleName}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateInviteDialog({
|
||||
roles,
|
||||
onCreateInvite,
|
||||
searchSpaceId,
|
||||
}: {
|
||||
roles: Role[];
|
||||
onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>;
|
||||
searchSpaceId: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [roleId, setRoleId] = useState<string>("");
|
||||
const [maxUses, setMaxUses] = useState<string>("");
|
||||
const [expiresAt, setExpiresAt] = useState<Date | undefined>(undefined);
|
||||
const [createdInvite, setCreatedInvite] = useState<Invite | null>(null);
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
|
||||
const assignableRoles = useMemo(() => roles.filter((r) => r.name !== "Owner"), [roles]);
|
||||
const defaultRole = useMemo(() => assignableRoles.find((r) => r.is_default), [assignableRoles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultRole && !roleId) {
|
||||
setRoleId(defaultRole.id.toString());
|
||||
}
|
||||
}, [defaultRole, roleId]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreating(true);
|
||||
try {
|
||||
const data: CreateInviteRequest["data"] = {};
|
||||
if (name) data.name = name;
|
||||
if (roleId) data.role_id = Number(roleId);
|
||||
if (maxUses) data.max_uses = Number(maxUses);
|
||||
if (expiresAt) data.expires_at = expiresAt.toISOString();
|
||||
|
||||
const invite = await onCreateInvite(data);
|
||||
setCreatedInvite(invite);
|
||||
|
||||
const roleName = roleId ? roles.find((r) => r.id.toString() === roleId)?.name : undefined;
|
||||
trackSearchSpaceInviteSent(searchSpaceId, {
|
||||
roleName,
|
||||
hasExpiry: !!expiresAt,
|
||||
hasMaxUses: !!maxUses,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to create invite:", error);
|
||||
toast.error("Failed to create invite. Please try again.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
setName("");
|
||||
setRoleId(defaultRole?.id.toString() ?? "");
|
||||
setMaxUses("");
|
||||
setExpiresAt(undefined);
|
||||
setCreatedInvite(null);
|
||||
setCopiedLink(false);
|
||||
};
|
||||
|
||||
const copyLink = () => {
|
||||
if (!createdInvite) return;
|
||||
const link = `${window.location.origin}/invite/${createdInvite.invite_code}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
setCopiedLink(true);
|
||||
toast.success("Invite link copied to clipboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => (v ? setOpen(true) : handleClose())}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 md:gap-2 text-xs md:text-sm bg-black text-white dark:bg-white dark:text-black hover:bg-black/90 dark:hover:bg-white/90"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
Invite members
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="w-[92vw] max-w-[92vw] sm:max-w-md p-4 md:p-6 select-none"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{createdInvite ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Check className="h-5 w-5 text-emerald-500" />
|
||||
Invite Created!
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Share this link to invite people to your search space.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2 md:py-4">
|
||||
<div className="flex items-center gap-2 p-3 bg-muted rounded-lg">
|
||||
<code className="flex-1 min-w-0 text-sm break-all">
|
||||
{window.location.origin}/invite/{createdInvite.invite_code}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={copyLink} className="shrink-0">
|
||||
{copiedLink ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
{createdInvite.role?.name || "Default role"}
|
||||
</span>
|
||||
{createdInvite.max_uses && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Hash className="h-3 w-3" />
|
||||
Max {createdInvite.max_uses} uses
|
||||
</span>
|
||||
)}
|
||||
{createdInvite.expires_at && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Expires {new Date(createdInvite.expires_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleClose}>Done</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invite Members</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a link to invite people to this search space.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2 md:py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-name">Name (optional)</Label>
|
||||
<Input
|
||||
id="invite-name"
|
||||
placeholder="e.g., Marketing team invite"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invite-role">Role</Label>
|
||||
<Select value={roleId} onValueChange={setRoleId}>
|
||||
<SelectTrigger className="border-popover-border">
|
||||
<SelectValue placeholder="Assign a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{assignableRoles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id.toString()}>
|
||||
<span className="flex items-center gap-2">
|
||||
{role.name}
|
||||
{role.is_default && (
|
||||
<span className="text-xs text-muted-foreground">(default)</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col md:grid md:grid-cols-2 gap-3 md:gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-uses">Max uses (optional)</Label>
|
||||
<Input
|
||||
id="max-uses"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Unlimited"
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Expires on (optional)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal bg-transparent border-popover-border",
|
||||
!expiresAt && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
{expiresAt ? expiresAt.toLocaleDateString() : "Never"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<CalendarComponent
|
||||
mode="single"
|
||||
selected={expiresAt}
|
||||
onSelect={setExpiresAt}
|
||||
disabled={(date) => date < new Date()}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={creating}>
|
||||
{creating ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Creating
|
||||
</>
|
||||
) : (
|
||||
"Create Invite"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AllInvitesDialog({
|
||||
invites,
|
||||
onRevokeInvite,
|
||||
}: {
|
||||
invites: Invite[];
|
||||
onRevokeInvite: (inviteId: number) => Promise<boolean>;
|
||||
}) {
|
||||
const [copiedId, setCopiedId] = useState<number | null>(null);
|
||||
|
||||
const copyLink = (invite: Invite) => {
|
||||
const link = `${window.location.origin}/invite/${invite.invite_code}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
setCopiedId(invite.id);
|
||||
toast.success("Invite link copied");
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 md:gap-2 rounded-md bg-muted px-3 text-xs md:text-sm hover:bg-accent"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 md:h-4 md:w-4 rotate-315" />
|
||||
Active invites
|
||||
<span className="inline-flex items-center justify-center h-4 md:h-5 min-w-4 md:min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200 text-[10px] md:text-xs font-medium">
|
||||
{invites.length}
|
||||
</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[92vw] max-w-[92vw] sm:max-w-lg p-4 md:p-6 select-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">Active Invite Links</DialogTitle>
|
||||
<DialogDescription>
|
||||
{invites.length} active {invites.length === 1 ? "invite" : "invites"}. Copy a link or
|
||||
revoke access.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[320px] overflow-y-auto -mx-1 px-1 space-y-3 py-2">
|
||||
{invites.map((invite) => (
|
||||
<div key={invite.id} className="rounded-lg border border-border/40 p-3 space-y-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{invite.name || "Unnamed invite"}</p>
|
||||
<div className="flex flex-wrap gap-x-2 text-xs text-muted-foreground shrink-0">
|
||||
{invite.role?.name && (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5">{invite.role.name}</span>
|
||||
)}
|
||||
{invite.max_uses != null && (
|
||||
<span className="flex items-center gap-1 rounded bg-muted px-1.5 py-0.5">
|
||||
<Hash className="h-3 w-3" />
|
||||
{invite.uses_count}/{invite.max_uses}
|
||||
</span>
|
||||
)}
|
||||
{invite.expires_at && (
|
||||
<span className="flex items-center gap-1 rounded bg-muted px-1.5 py-0.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(invite.expires_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revoke invite?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this invite link. Anyone with this link will no
|
||||
longer be able to join.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onRevokeInvite(invite.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Revoke
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-md bg-muted p-2">
|
||||
<div className="flex-1 min-w-0 overflow-x-auto scrollbar-hide">
|
||||
<code className="text-sm select-all whitespace-nowrap">
|
||||
{typeof window !== "undefined"
|
||||
? `${window.location.origin}/invite/${invite.invite_code}`
|
||||
: `/invite/${invite.invite_code}`}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => copyLink(invite)}
|
||||
>
|
||||
{copiedId === invite.id ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { AgentPermissionsContent } from "../components/AgentPermissionsContent";
|
||||
|
||||
export default function Page() {
|
||||
return <AgentPermissionsContent />;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { AgentStatusContent } from "../components/AgentStatusContent";
|
||||
|
||||
export default function Page() {
|
||||
return <AgentStatusContent />;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { ApiKeyContent } from "../components/ApiKeyContent";
|
||||
|
||||
export default function Page() {
|
||||
return <ApiKeyContent />;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { CommunityPromptsContent } from "../components/CommunityPromptsContent";
|
||||
|
||||
export default function Page() {
|
||||
return <CommunityPromptsContent />;
|
||||
}
|
||||
|
|
@ -1,487 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertTriangle, Info, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
type AgentPermissionAction,
|
||||
type AgentPermissionRule,
|
||||
type AgentPermissionRuleCreate,
|
||||
agentPermissionsApiService,
|
||||
} from "@/lib/apis/agent-permissions-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ACTION_DESCRIPTIONS: Record<AgentPermissionAction, string> = {
|
||||
allow: "Always run without prompting",
|
||||
deny: "Block silently",
|
||||
ask: "Pause and ask for approval",
|
||||
};
|
||||
|
||||
const ACTION_BADGE: Record<AgentPermissionAction, { label: string; className: string }> = {
|
||||
allow: { label: "Allow", className: "bg-emerald-500/10 text-emerald-600 border-emerald-500/30" },
|
||||
deny: { label: "Deny", className: "bg-destructive/10 text-destructive border-destructive/30" },
|
||||
ask: { label: "Ask", className: "bg-amber-500/10 text-amber-600 border-amber-500/30" },
|
||||
};
|
||||
|
||||
const EMPTY_FORM: AgentPermissionRuleCreate = {
|
||||
permission: "",
|
||||
pattern: "*",
|
||||
action: "ask",
|
||||
user_id: null,
|
||||
thread_id: null,
|
||||
};
|
||||
|
||||
function permissionRulesQueryKey(searchSpaceId: number) {
|
||||
return ["agent-permission-rules", searchSpaceId] as const;
|
||||
}
|
||||
|
||||
function ScopeBadge({ rule }: { rule: AgentPermissionRule }) {
|
||||
if (rule.thread_id !== null) {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
|
||||
>
|
||||
Thread #{rule.thread_id}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (rule.user_id !== null) {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
|
||||
>
|
||||
User-specific
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
|
||||
>
|
||||
Search space
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentPermissionsContent() {
|
||||
const searchSpaceIdRaw = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const searchSpaceId = searchSpaceIdRaw ? Number(searchSpaceIdRaw) : null;
|
||||
|
||||
const { data: flags } = useAtomValue(agentFlagsAtom);
|
||||
const featureEnabled = !!flags?.enable_permission && !flags?.disable_new_agent_stack;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: rules,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: searchSpaceId
|
||||
? permissionRulesQueryKey(searchSpaceId)
|
||||
: ["agent-permission-rules", "none"],
|
||||
queryFn: () => agentPermissionsApiService.list(searchSpaceId as number),
|
||||
enabled: !!searchSpaceId && featureEnabled,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (payload: AgentPermissionRuleCreate) =>
|
||||
agentPermissionsApiService.create(searchSpaceId as number, payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Rule created.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionRulesQueryKey(searchSpaceId as number),
|
||||
});
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to create rule.");
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (params: { ruleId: number; action: AgentPermissionAction; pattern?: string }) =>
|
||||
agentPermissionsApiService.update(searchSpaceId as number, params.ruleId, {
|
||||
action: params.action,
|
||||
pattern: params.pattern,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionRulesQueryKey(searchSpaceId as number),
|
||||
});
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to update rule.");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (ruleId: number) =>
|
||||
agentPermissionsApiService.remove(searchSpaceId as number, ruleId),
|
||||
onSuccess: () => {
|
||||
toast.success("Rule deleted.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionRulesQueryKey(searchSpaceId as number),
|
||||
});
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to delete rule.");
|
||||
},
|
||||
});
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [formData, setFormData] = useState<AgentPermissionRuleCreate>(EMPTY_FORM);
|
||||
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
||||
|
||||
const sortedRules = useMemo(() => rules ?? [], [rules]);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!formData.permission.trim()) {
|
||||
toast.error("Permission is required.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
...formData,
|
||||
permission: formData.permission.trim(),
|
||||
pattern: formData.pattern.trim() || "*",
|
||||
});
|
||||
setFormData(EMPTY_FORM);
|
||||
setShowForm(false);
|
||||
} catch (err) {
|
||||
if (err instanceof AppError && err.message) {
|
||||
// already toasted by onError
|
||||
}
|
||||
}
|
||||
}, [createMutation, formData]);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (deleteTarget === null) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(deleteTarget);
|
||||
} finally {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}, [deleteMutation, deleteTarget]);
|
||||
|
||||
if (!featureEnabled) {
|
||||
return (
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertTitle>Permission middleware is disabled</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
Flip{" "}
|
||||
<code className="rounded bg-popover px-1 py-0.5 text-[10px] text-popover-foreground">
|
||||
SURFSENSE_ENABLE_PERMISSION
|
||||
</code>{" "}
|
||||
on the backend to manage allow/deny/ask rules from this panel.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!searchSpaceId) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">Open a search space to manage agent rules.</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-6 overflow-visible">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tell the agent which tools to allow, deny, or ask before running. Rules use wildcard
|
||||
patterns and are evaluated at the most specific scope first.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(true);
|
||||
setFormData(EMPTY_FORM);
|
||||
}}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
New rule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={showForm}
|
||||
onOpenChange={(open) => {
|
||||
setShowForm(open);
|
||||
if (!open) setFormData(EMPTY_FORM);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg bg-popover text-popover-foreground">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New permission rule</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tell the agent whether matching tool calls should be allowed, denied, or paused for
|
||||
approval.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="permission-name">Permission</Label>
|
||||
<Input
|
||||
id="permission-name"
|
||||
value={formData.permission}
|
||||
placeholder="e.g. tool:create_linear_issue or tool:*"
|
||||
onChange={(e) => setFormData((p) => ({ ...p, permission: e.target.value }))}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Match a tool capability. Use <code className="font-mono">*</code> for wildcards.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pattern">Argument pattern</Label>
|
||||
<Input
|
||||
id="pattern"
|
||||
value={formData.pattern}
|
||||
placeholder="*"
|
||||
onChange={(e) => setFormData((p) => ({ ...p, pattern: e.target.value }))}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Wildcard against the canonical argument (e.g. <code>prod-*</code>).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Action</Label>
|
||||
<Select
|
||||
value={formData.action}
|
||||
onValueChange={(value) =>
|
||||
setFormData((p) => ({ ...p, action: value as AgentPermissionAction }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="allow">Allow (run without asking)</SelectItem>
|
||||
<SelectItem value="ask">Ask (pause for approval)</SelectItem>
|
||||
<SelectItem value="deny">Deny (block silently)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{ACTION_DESCRIPTIONS[formData.action]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setFormData(EMPTY_FORM);
|
||||
}}
|
||||
disabled={createMutation.isPending}
|
||||
className="text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={createMutation.isPending || !formData.permission.trim()}
|
||||
className="relative text-sm h-9 min-w-[96px]"
|
||||
>
|
||||
<span className={createMutation.isPending ? "opacity-0" : ""}>Create</span>
|
||||
{createMutation.isPending && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isLoading && (
|
||||
<div className="-m-1 space-y-2 p-1">
|
||||
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||
<Card key={key} className="border-accent bg-accent/20">
|
||||
<CardContent className="p-4 flex flex-col gap-3 min-h-24">
|
||||
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
|
||||
<Skeleton className="h-3 w-full bg-accent" />
|
||||
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>Failed to load rules</AlertTitle>
|
||||
<AlertDescription>
|
||||
{error instanceof Error ? error.message : "Unknown error."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && sortedRules.length === 0 && !showForm && (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
|
||||
<ShieldCheck className="mx-auto size-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No rules yet</p>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Without rules the agent uses the deployment default for every tool.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && sortedRules.length > 0 && (
|
||||
<div className="-m-1 space-y-2 p-1">
|
||||
{sortedRules.map((rule) => {
|
||||
const badge = ACTION_BADGE[rule.action];
|
||||
const isUpdating =
|
||||
updateMutation.isPending && updateMutation.variables?.ruleId === rule.id;
|
||||
const isDeleting = deleteMutation.isPending && deleteMutation.variables === rule.id;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={rule.id}
|
||||
className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full"
|
||||
>
|
||||
<CardContent className="p-4 flex items-center justify-between gap-3 h-full">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<code className="truncate font-mono text-sm font-medium text-foreground">
|
||||
{rule.permission}
|
||||
</code>
|
||||
{rule.pattern !== "*" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
→ <code className="font-mono">{rule.pattern}</code>
|
||||
</span>
|
||||
)}
|
||||
<ScopeBadge rule={rule} />
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Created {formatRelativeDate(rule.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center self-center gap-1">
|
||||
<Select
|
||||
value={rule.action}
|
||||
onValueChange={(value) =>
|
||||
updateMutation.mutate({
|
||||
ruleId: rule.id,
|
||||
action: value as AgentPermissionAction,
|
||||
})
|
||||
}
|
||||
disabled={isUpdating || isDeleting}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn("h-8 gap-1 border px-2 text-[11px]", badge.className)}
|
||||
>
|
||||
<SelectValue>
|
||||
<span className="flex items-center gap-1">{badge.label}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="allow">Allow</SelectItem>
|
||||
<SelectItem value="ask">Ask</SelectItem>
|
||||
<SelectItem value="deny">Deny</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-lg p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(rule.id)}
|
||||
disabled={isUpdating || isDeleting}
|
||||
aria-label="Delete rule"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this rule?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The agent will fall back to deployment defaults for matching tool calls.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleConfirmDelete();
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="relative min-w-[88px]"
|
||||
>
|
||||
<span className={deleteMutation.isPending ? "opacity-0" : ""}>Delete</span>
|
||||
{deleteMutation.isPending && <Spinner size="sm" className="absolute" />}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Check, Copy, Info, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { usePats } from "@/hooks/use-pats";
|
||||
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
|
||||
|
||||
export function ApiKeyContent() {
|
||||
const { tokens, createdToken, setCreatedToken, isLoading, isMutating, createToken, deleteToken } =
|
||||
usePats();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [label, setLabel] = useState("");
|
||||
const [expiresInDays, setExpiresInDays] = useState("");
|
||||
const [copiedToken, setCopiedToken] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: number; label: string } | null>(null);
|
||||
|
||||
const sortedTokens = useMemo(() => tokens, [tokens]);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
const trimmedLabel = label.trim();
|
||||
if (!trimmedLabel) return;
|
||||
|
||||
await createToken({
|
||||
label: trimmedLabel,
|
||||
expires_in_days: expiresInDays ? Number(expiresInDays) : null,
|
||||
});
|
||||
setLabel("");
|
||||
setExpiresInDays("");
|
||||
setCreateOpen(false);
|
||||
}, [createToken, expiresInDays, label]);
|
||||
|
||||
const copyCreatedToken = useCallback(async () => {
|
||||
if (!createdToken) return;
|
||||
const success = await copyToClipboardUtil(createdToken.token);
|
||||
if (success) {
|
||||
setCopiedToken(true);
|
||||
setTimeout(() => setCopiedToken(false), 2000);
|
||||
}
|
||||
}, [createdToken]);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
await deleteToken(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
}, [deleteTarget, deleteToken]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 min-w-0">
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
API keys let extensions, Obsidian, and other apps connect to SurfSense.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold tracking-tight">API keys</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Expired API keys stay listed until you delete them.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
Create API key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="-m-1 grid grid-cols-1 gap-3 p-1">
|
||||
{["skeleton-a", "skeleton-b"].map((key) => (
|
||||
<Card
|
||||
key={key}
|
||||
className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full"
|
||||
>
|
||||
<CardContent className="p-4 flex flex-col gap-3 h-full min-h-24">
|
||||
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
|
||||
<Skeleton className="h-3 w-full bg-accent" />
|
||||
<Skeleton className="h-3 w-24 md:w-28 bg-accent" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : sortedTokens.length > 0 ? (
|
||||
<div className="-m-1 grid grid-cols-1 gap-3 p-1">
|
||||
{sortedTokens.map((token) => {
|
||||
const expiresAt = token.expires_at ? new Date(token.expires_at) : null;
|
||||
const isExpired = expiresAt ? expiresAt.getTime() <= Date.now() : false;
|
||||
return (
|
||||
<Card
|
||||
key={token.id}
|
||||
className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full"
|
||||
>
|
||||
<CardContent className="flex min-h-24 items-center gap-3 p-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="truncate text-sm font-semibold tracking-tight">
|
||||
{token.label}
|
||||
</h4>
|
||||
{isExpired ? (
|
||||
<span className="rounded-md border-0 bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
Expired
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">
|
||||
{token.prefix}...
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Expires: {expiresAt ? expiresAt.toLocaleDateString() : "Never"} · Last used:{" "}
|
||||
{token.last_used_at
|
||||
? new Date(token.last_used_at).toLocaleString()
|
||||
: "Never"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={isMutating}
|
||||
onClick={() => setDeleteTarget({ id: token.id, label: token.label })}
|
||||
className="h-7 w-7 shrink-0 rounded-lg text-muted-foreground transition-opacity duration-150 hover:text-accent-foreground sm:opacity-0 sm:pointer-events-none sm:group-hover:opacity-100 sm:group-hover:pointer-events-auto"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No API keys yet.</p>
|
||||
)}
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Name this API key so you can recognize where it is used later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pat-label">Name</Label>
|
||||
<Input
|
||||
id="pat-label"
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
placeholder="Obsidian vault"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pat-expiry">Expires in days (optional)</Label>
|
||||
<Input
|
||||
id="pat-expiry"
|
||||
type="number"
|
||||
min={1}
|
||||
value={expiresInDays}
|
||||
onChange={(event) => setExpiresInDays(event.target.value)}
|
||||
placeholder="Never expires"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
disabled={isMutating}
|
||||
className="text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isMutating || !label.trim()}
|
||||
onClick={handleCreate}
|
||||
className="relative text-sm h-9 min-w-[128px]"
|
||||
>
|
||||
<span className={isMutating ? "opacity-0" : ""}>Create API key</span>
|
||||
{isMutating && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!createdToken} onOpenChange={(open) => !open && setCreatedToken(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Copy your API key now</DialogTitle>
|
||||
<DialogDescription>
|
||||
This API key is shown only once. Store it somewhere secure before closing this dialog.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 p-2">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-xs">
|
||||
{createdToken?.token}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyCreatedToken}
|
||||
className="border-0 bg-muted/30 hover:bg-muted/50"
|
||||
>
|
||||
{copiedToken ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setCreatedToken(null)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete API key?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<span className="font-medium text-foreground">{deleteTarget?.label}</span> will be
|
||||
permanently removed. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isMutating}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isMutating}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void handleConfirmDelete();
|
||||
}}
|
||||
>
|
||||
{isMutating ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Spinner size="xs" />
|
||||
Deleting...
|
||||
</span>
|
||||
) : (
|
||||
"Delete"
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertTriangle, Copy, Library } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { copyPromptMutationAtom } from "@/atoms/prompts/prompts-mutation.atoms";
|
||||
import { publicPromptsAtom } from "@/atoms/prompts/prompts-query.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
export function CommunityPromptsContent() {
|
||||
const { data: prompts, isLoading, isError } = useAtomValue(publicPromptsAtom);
|
||||
const { mutateAsync: copyPrompt } = useAtomValue(copyPromptMutationAtom);
|
||||
const [copyingIds, setCopyingIds] = useState<Set<number>>(new Set());
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
async (id: number) => {
|
||||
setCopyingIds((prev) => new Set(prev).add(id));
|
||||
try {
|
||||
await copyPrompt(id);
|
||||
} catch {
|
||||
// toast handled by mutation atom
|
||||
} finally {
|
||||
setCopyingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[copyPrompt]
|
||||
);
|
||||
|
||||
const list = prompts ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 min-w-0">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Prompts shared by other users. Add any to your collection with one click.
|
||||
</p>
|
||||
|
||||
{isLoading && (
|
||||
<div className="-m-1 space-y-2 p-1">
|
||||
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||
<Card key={key} className="border-accent bg-accent/20">
|
||||
<CardContent className="p-4 flex flex-col gap-3 min-h-24">
|
||||
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
|
||||
<Skeleton className="h-3 w-full bg-accent" />
|
||||
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>Failed to load community prompts</AlertTitle>
|
||||
<AlertDescription>Please try refreshing the page.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && list.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
|
||||
<Library className="mx-auto size-8 text-muted-foreground" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No community prompts yet</p>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Share your own prompts from the My Prompts tab
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && list.length > 0 && (
|
||||
<div className="-m-1 space-y-2 p-1">
|
||||
{list.map((prompt) => (
|
||||
<Card
|
||||
key={prompt.id}
|
||||
className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full"
|
||||
>
|
||||
<CardContent className="p-4 flex items-start gap-3 h-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{prompt.name}</span>
|
||||
<span className="rounded-md border-0 bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{prompt.mode}
|
||||
</span>
|
||||
{prompt.author_name && (
|
||||
<span className="text-[11px] text-muted-foreground/60">
|
||||
by {prompt.author_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={`mt-1 text-xs text-muted-foreground ${expandedId === prompt.id ? "whitespace-pre-wrap" : "line-clamp-2"}`}
|
||||
>
|
||||
{prompt.prompt}
|
||||
</p>
|
||||
{prompt.prompt.length > 100 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)}
|
||||
className="mt-1 h-auto cursor-pointer px-0 py-0 text-[11px] text-primary"
|
||||
>
|
||||
{expandedId === prompt.id ? "See less" : "See more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 gap-1.5 rounded-lg px-2 text-muted-foreground hover:text-accent-foreground"
|
||||
disabled={copyingIds.has(prompt.id)}
|
||||
onClick={() => handleCopy(prompt.id)}
|
||||
>
|
||||
{copyingIds.has(prompt.id) ? (
|
||||
<Spinner className="size-3" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
Add to mine
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,231 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { SearchSpace } from "@/contracts/types/search-space.types";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
|
||||
export function DesktopContent() {
|
||||
const api = useElectronAPI();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
|
||||
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null);
|
||||
|
||||
const [autoLaunchEnabled, setAutoLaunchEnabled] = useState(false);
|
||||
const [autoLaunchHidden, setAutoLaunchHidden] = useState(true);
|
||||
const [autoLaunchSupported, setAutoLaunchSupported] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
const hasAutoLaunchApi =
|
||||
typeof api.getAutoLaunch === "function" && typeof api.setAutoLaunch === "function";
|
||||
setAutoLaunchSupported(hasAutoLaunchApi);
|
||||
|
||||
Promise.all([
|
||||
api.getActiveSearchSpace?.() ?? Promise.resolve(null),
|
||||
searchSpacesApiService.getSearchSpaces(),
|
||||
hasAutoLaunchApi ? api.getAutoLaunch() : Promise.resolve(null),
|
||||
])
|
||||
.then(([spaceId, spaces, autoLaunch]) => {
|
||||
if (!mounted) return;
|
||||
setActiveSpaceId(spaceId);
|
||||
if (spaces) setSearchSpaces(spaces);
|
||||
if (autoLaunch) {
|
||||
setAutoLaunchEnabled(autoLaunch.enabled);
|
||||
setAutoLaunchHidden(autoLaunch.openAsHidden);
|
||||
setAutoLaunchSupported(autoLaunch.supported);
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!mounted) return;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
if (!api) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
App preferences are only available in the SurfSense desktop app.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<section>
|
||||
<div className="flex flex-col gap-2 pb-2 md:pb-3">
|
||||
<Skeleton className="h-6 w-48 bg-accent" />
|
||||
<Skeleton className="h-4 w-full max-w-2xl bg-accent" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full bg-accent" />
|
||||
</section>
|
||||
|
||||
<Separator className="bg-border" />
|
||||
|
||||
<section>
|
||||
<div className="flex flex-col gap-2 pb-2 md:pb-3">
|
||||
<Skeleton className="h-6 w-44 bg-accent" />
|
||||
<Skeleton className="h-4 w-full max-w-3xl bg-accent" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-20 w-full bg-accent" />
|
||||
<Skeleton className="h-20 w-full bg-accent" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAutoLaunchToggle = async (checked: boolean) => {
|
||||
if (!autoLaunchSupported || !api.setAutoLaunch) {
|
||||
toast.error("Please update the desktop app to configure launch on startup");
|
||||
return;
|
||||
}
|
||||
setAutoLaunchEnabled(checked);
|
||||
try {
|
||||
const next = await api.setAutoLaunch(checked, autoLaunchHidden);
|
||||
if (next) {
|
||||
setAutoLaunchEnabled(next.enabled);
|
||||
setAutoLaunchHidden(next.openAsHidden);
|
||||
setAutoLaunchSupported(next.supported);
|
||||
}
|
||||
toast.success(checked ? "SurfSense will launch on startup" : "Launch on startup disabled");
|
||||
} catch {
|
||||
setAutoLaunchEnabled(!checked);
|
||||
toast.error("Failed to update launch on startup");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoLaunchHiddenToggle = async (checked: boolean) => {
|
||||
if (!autoLaunchSupported || !api.setAutoLaunch) {
|
||||
toast.error("Please update the desktop app to configure startup behavior");
|
||||
return;
|
||||
}
|
||||
setAutoLaunchHidden(checked);
|
||||
try {
|
||||
await api.setAutoLaunch(autoLaunchEnabled, checked);
|
||||
} catch {
|
||||
setAutoLaunchHidden(!checked);
|
||||
toast.error("Failed to update startup behavior");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchSpaceChange = (value: string) => {
|
||||
setActiveSpaceId(value);
|
||||
api.setActiveSearchSpace?.(value);
|
||||
toast.success("Default search space updated");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:gap-6">
|
||||
<section>
|
||||
<div className="pb-2 md:pb-3">
|
||||
<h2 className="text-base md:text-lg font-semibold">Default Search Space</h2>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">
|
||||
Choose which search space General Assist, Screenshot Assist, and Quick Assist use by
|
||||
default.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{searchSpaces.length > 0 ? (
|
||||
<Select value={activeSpaceId ?? undefined} onValueChange={handleSearchSpaceChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a search space" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{searchSpaces.map((space) => (
|
||||
<SelectItem key={space.id} value={String(space.id)}>
|
||||
{space.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No search spaces found. Create one first.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator className="bg-border" />
|
||||
|
||||
<section>
|
||||
<div className="pb-2 md:pb-3">
|
||||
<h2 className="text-base md:text-lg font-semibold flex items-center gap-2">
|
||||
Launch on Startup
|
||||
</h2>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">
|
||||
Automatically start SurfSense when you sign in to your computer so global shortcuts and
|
||||
folder sync are always available.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between rounded-lg bg-accent p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auto-launch-toggle" className="text-sm font-medium cursor-pointer">
|
||||
Open SurfSense at login
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{autoLaunchSupported
|
||||
? "Adds SurfSense to your system's login items."
|
||||
: "Only available in the packaged desktop app."}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-launch-toggle"
|
||||
checked={autoLaunchEnabled}
|
||||
onCheckedChange={handleAutoLaunchToggle}
|
||||
disabled={!autoLaunchSupported}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg bg-accent p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="auto-launch-hidden-toggle"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Start minimized to tray
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skip the main window on boot. SurfSense lives in the system tray until you need it.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-launch-hidden-toggle"
|
||||
checked={autoLaunchHidden}
|
||||
onCheckedChange={handleAutoLaunchHiddenToggle}
|
||||
disabled={!autoLaunchSupported || !autoLaunchEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Crop, Rocket, RotateCcw, Zap } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DEFAULT_SHORTCUTS, keyEventToAccelerator } from "@/components/desktop/shortcut-recorder";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
|
||||
type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist";
|
||||
type ShortcutMap = typeof DEFAULT_SHORTCUTS;
|
||||
|
||||
const HOTKEY_ROWS: Array<{ key: ShortcutKey; label: string; icon: React.ElementType }> = [
|
||||
{ key: "generalAssist", label: "General Assist", icon: Rocket },
|
||||
{ key: "screenshotAssist", label: "Screenshot Assist", icon: Crop },
|
||||
{ key: "quickAsk", label: "Quick Assist", icon: Zap },
|
||||
];
|
||||
|
||||
function acceleratorToKeys(accel: string, isMac: boolean): string[] {
|
||||
if (!accel) return [];
|
||||
return accel.split("+").map((part) => {
|
||||
if (part === "CommandOrControl") {
|
||||
return isMac ? "⌘" : "Ctrl";
|
||||
}
|
||||
if (part === "Alt") {
|
||||
return isMac ? "⌥" : "Alt";
|
||||
}
|
||||
if (part === "Shift") {
|
||||
return isMac ? "⇧" : "Shift";
|
||||
}
|
||||
if (part === "Space") return "Space";
|
||||
return part.length === 1 ? part.toUpperCase() : part;
|
||||
});
|
||||
}
|
||||
|
||||
function HotkeyRow({
|
||||
label,
|
||||
value,
|
||||
defaultValue,
|
||||
icon: Icon,
|
||||
isMac,
|
||||
onChange,
|
||||
onReset,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
defaultValue: string;
|
||||
icon: React.ElementType;
|
||||
isMac: boolean;
|
||||
onChange: (accelerator: string) => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const inputRef = useRef<HTMLButtonElement>(null);
|
||||
const isDefault = value === defaultValue;
|
||||
const displayKeys = useMemo(() => acceleratorToKeys(value, isMac), [value, isMac]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!recording) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === "Escape") {
|
||||
setRecording(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const accel = keyEventToAccelerator(e);
|
||||
if (accel) {
|
||||
onChange(accel);
|
||||
setRecording(false);
|
||||
}
|
||||
},
|
||||
[onChange, recording]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2.5 py-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Icon className="size-3.5" />
|
||||
</div>
|
||||
<p className="text-sm text-foreground truncate">{label}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!isDefault && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-accent-foreground"
|
||||
onClick={onReset}
|
||||
title="Reset to default"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
ref={inputRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
title={recording ? "Press shortcut keys" : "Click to edit shortcut"}
|
||||
onClick={() => setRecording(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => setRecording(false)}
|
||||
className={
|
||||
recording
|
||||
? "h-7 border border-transparent bg-primary/5 px-0 outline-none ring-0 focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||
: "h-7 cursor-pointer border border-transparent bg-transparent px-0 outline-none ring-0 transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||
}
|
||||
>
|
||||
{recording ? (
|
||||
<span className="px-2 text-[9px] text-primary whitespace-nowrap">Press hotkeys</span>
|
||||
) : (
|
||||
<ShortcutKbd keys={displayKeys} className="ml-0 px-1.5 text-foreground/85" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HotkeysContent() {
|
||||
const api = useElectronAPI();
|
||||
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
|
||||
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
|
||||
const isMac = api?.versions?.platform === "darwin";
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
setShortcutsLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
(api.getShortcuts?.() ?? Promise.resolve(null))
|
||||
.then((config: ShortcutMap | null) => {
|
||||
if (!mounted) return;
|
||||
if (config) setShortcuts(config);
|
||||
setShortcutsLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!mounted) return;
|
||||
setShortcutsLoaded(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
if (!api) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Hotkeys are only available in the SurfSense desktop app.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const updateShortcut = (key: ShortcutKey, accelerator: string) => {
|
||||
setShortcuts((prev) => {
|
||||
const updated = { ...prev, [key]: accelerator };
|
||||
api.setShortcuts?.({ [key]: accelerator }).catch(() => {
|
||||
toast.error("Failed to update shortcut");
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
toast.success("Shortcut updated");
|
||||
};
|
||||
|
||||
const resetShortcut = (key: ShortcutKey) => {
|
||||
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
|
||||
};
|
||||
|
||||
return shortcutsLoaded ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
{HOTKEY_ROWS.map((row, index) => (
|
||||
<div key={row.key}>
|
||||
<HotkeyRow
|
||||
label={row.label}
|
||||
value={shortcuts[row.key]}
|
||||
defaultValue={DEFAULT_SHORTCUTS[row.key]}
|
||||
icon={row.icon}
|
||||
isMac={isMac}
|
||||
onChange={(accel) => updateShortcut(row.key, accel)}
|
||||
onReset={() => resetShortcut(row.key)}
|
||||
/>
|
||||
{index < HOTKEY_ROWS.length - 1 ? <Separator className="bg-border" /> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center py-4">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,603 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { AlertTriangle, RefreshCw, ShieldAlert } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { SearchSpace } from "@/contracts/types/search-space.types";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-fetch";
|
||||
import { buildBackendUrl } from "@/lib/env-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type GatewayConnection = {
|
||||
id: number;
|
||||
account_id?: number | null;
|
||||
route_type?: "account" | "binding";
|
||||
platform: string;
|
||||
mode?: string;
|
||||
state: string;
|
||||
search_space_id: number;
|
||||
display_name?: string | null;
|
||||
external_username?: string | null;
|
||||
workspace_name?: string | null;
|
||||
workspace_id?: string | null;
|
||||
health_status: string;
|
||||
suspended_reason?: string | null;
|
||||
};
|
||||
|
||||
type GatewayConfig = {
|
||||
enabled: boolean;
|
||||
telegram_enabled: boolean;
|
||||
whatsapp_intake_mode: "disabled" | "cloud" | "baileys";
|
||||
slack_enabled: boolean;
|
||||
discord_enabled: boolean;
|
||||
};
|
||||
|
||||
type GatewayConfigState = GatewayConfig | null;
|
||||
|
||||
const DISABLED_GATEWAY_CONFIG: GatewayConfig = {
|
||||
enabled: false,
|
||||
telegram_enabled: false,
|
||||
whatsapp_intake_mode: "disabled",
|
||||
slack_enabled: false,
|
||||
discord_enabled: false,
|
||||
};
|
||||
|
||||
type Pairing = {
|
||||
binding_id: number;
|
||||
code: string;
|
||||
deep_link: string;
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
type PairingPlatform = "telegram" | "whatsapp";
|
||||
type GatewayPlatform = PairingPlatform | "slack" | "discord";
|
||||
|
||||
type BaileysHealth = {
|
||||
status: string;
|
||||
hasQr: boolean;
|
||||
qr?: string | null;
|
||||
queueDepth?: number;
|
||||
user?: unknown;
|
||||
};
|
||||
|
||||
export function MessagingChannelsContent() {
|
||||
const params = useParams<{ search_space_id: string }>();
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const [gatewayConfig, setGatewayConfig] = useState<GatewayConfigState>(null);
|
||||
const [connections, setConnections] = useState<GatewayConnection[]>([]);
|
||||
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
|
||||
const [pairing, setPairing] = useState<Pairing | null>(null);
|
||||
const [pairingPlatform, setPairingPlatform] = useState<PairingPlatform | null>(null);
|
||||
const [baileysHealth, setBaileysHealth] = useState<BaileysHealth | null>(null);
|
||||
const [refreshingPlatform, setRefreshingPlatform] = useState<GatewayPlatform | null>(null);
|
||||
const isGatewayConfigLoading = gatewayConfig === null;
|
||||
const telegramGatewayEnabled = gatewayConfig?.telegram_enabled ?? false;
|
||||
const whatsappMode = gatewayConfig?.whatsapp_intake_mode ?? "disabled";
|
||||
const slackGatewayEnabled = gatewayConfig?.slack_enabled ?? false;
|
||||
const discordGatewayEnabled = gatewayConfig?.discord_enabled ?? false;
|
||||
const gatewayDisabled = gatewayConfig?.enabled === false;
|
||||
|
||||
const fetchConnections = useCallback(async (platform?: GatewayPlatform) => {
|
||||
const res = await authenticatedFetch(
|
||||
buildBackendUrl("/api/v1/gateway/connections", platform ? { platform } : undefined)
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? (data as GatewayConnection[]) : [];
|
||||
}, []);
|
||||
|
||||
const fetchGatewayConfig = useCallback(async (): Promise<GatewayConfig> => {
|
||||
const res = await authenticatedFetch(buildBackendUrl("/api/v1/gateway/config"));
|
||||
if (!res.ok) return DISABLED_GATEWAY_CONFIG;
|
||||
const data = (await res.json()) as Partial<GatewayConfig>;
|
||||
return {
|
||||
...DISABLED_GATEWAY_CONFIG,
|
||||
...data,
|
||||
enabled: data.enabled ?? true,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [nextConnections, spaces, nextGatewayConfig] = await Promise.all([
|
||||
fetchConnections(),
|
||||
searchSpacesApiService.getSearchSpaces(),
|
||||
fetchGatewayConfig(),
|
||||
]);
|
||||
setConnections(nextConnections);
|
||||
setSearchSpaces(spaces);
|
||||
setGatewayConfig(nextGatewayConfig);
|
||||
}, [fetchConnections, fetchGatewayConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const refreshPlatform = useCallback(
|
||||
async (platform: GatewayPlatform) => {
|
||||
setRefreshingPlatform(platform);
|
||||
try {
|
||||
const nextConnections = await fetchConnections(platform);
|
||||
setConnections((current) => [
|
||||
...current.filter((connection) => connection.platform !== platform),
|
||||
...nextConnections,
|
||||
]);
|
||||
} finally {
|
||||
setRefreshingPlatform(null);
|
||||
}
|
||||
},
|
||||
[fetchConnections]
|
||||
);
|
||||
|
||||
const refreshBaileysHealth = useCallback(async () => {
|
||||
if (whatsappMode !== "baileys") return;
|
||||
const res = await authenticatedFetch(
|
||||
buildBackendUrl("/api/v1/gateway/whatsapp/baileys/health")
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as BaileysHealth;
|
||||
setBaileysHealth(data);
|
||||
}, [whatsappMode]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshBaileysHealth();
|
||||
}, [refreshBaileysHealth]);
|
||||
|
||||
async function startPairing(platform: PairingPlatform) {
|
||||
const res = await authenticatedFetch(buildBackendUrl("/api/v1/gateway/bindings/start"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ platform, search_space_id: searchSpaceId }),
|
||||
});
|
||||
setPairing(await res.json());
|
||||
setPairingPlatform(platform);
|
||||
await refreshPlatform(platform);
|
||||
}
|
||||
|
||||
async function installSlackGateway() {
|
||||
const res = await authenticatedFetch(
|
||||
buildBackendUrl("/api/v1/gateway/slack/install", { search_space_id: searchSpaceId })
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { auth_url?: string };
|
||||
if (data.auth_url) {
|
||||
window.location.href = data.auth_url;
|
||||
}
|
||||
}
|
||||
|
||||
async function installDiscordGateway() {
|
||||
const res = await authenticatedFetch(
|
||||
buildBackendUrl("/api/v1/gateway/discord/install", { search_space_id: searchSpaceId })
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { auth_url?: string };
|
||||
if (data.auth_url) {
|
||||
window.location.href = data.auth_url;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBaileys() {
|
||||
await refreshBaileysHealth();
|
||||
await refreshPlatform("whatsapp");
|
||||
}
|
||||
|
||||
const connectionKey = (connection: GatewayConnection) =>
|
||||
connection.route_type === "account" && connection.account_id
|
||||
? `account:${connection.account_id}`
|
||||
: `binding:${connection.id}`;
|
||||
|
||||
async function revoke(connection: GatewayConnection) {
|
||||
const url =
|
||||
connection.route_type === "account" && connection.account_id
|
||||
? buildBackendUrl(`/api/v1/gateway/accounts/${connection.account_id}`)
|
||||
: buildBackendUrl(`/api/v1/gateway/bindings/${connection.id}`);
|
||||
await authenticatedFetch(url, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await refreshPlatform(connection.platform as GatewayPlatform);
|
||||
}
|
||||
|
||||
async function updateConnectionSearchSpace(
|
||||
connection: GatewayConnection,
|
||||
nextSearchSpaceId: string
|
||||
) {
|
||||
const previousConnections = connections;
|
||||
const parsedSearchSpaceId = Number(nextSearchSpaceId);
|
||||
const targetKey = connectionKey(connection);
|
||||
setConnections((current) =>
|
||||
current.map((connection) =>
|
||||
connectionKey(connection) === targetKey
|
||||
? { ...connection, search_space_id: parsedSearchSpaceId }
|
||||
: connection
|
||||
)
|
||||
);
|
||||
const url =
|
||||
connection.route_type === "account" && connection.account_id
|
||||
? buildBackendUrl(`/api/v1/gateway/accounts/${connection.account_id}/search-space`)
|
||||
: buildBackendUrl(`/api/v1/gateway/bindings/${connection.id}/search-space`);
|
||||
const res = await authenticatedFetch(url, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ search_space_id: parsedSearchSpaceId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setConnections(previousConnections);
|
||||
toast.error("Failed to update messaging route");
|
||||
return;
|
||||
}
|
||||
toast.success("Messaging route updated");
|
||||
await refreshPlatform(connection.platform as GatewayPlatform);
|
||||
}
|
||||
|
||||
async function resume(connection: GatewayConnection) {
|
||||
await authenticatedFetch(buildBackendUrl(`/api/v1/gateway/bindings/${connection.id}/resume`), {
|
||||
method: "POST",
|
||||
});
|
||||
await refreshPlatform(connection.platform as GatewayPlatform);
|
||||
}
|
||||
|
||||
const isConnectionInActiveMode = (connection: GatewayConnection) => {
|
||||
if (connection.platform !== "whatsapp") return true;
|
||||
if (whatsappMode === "baileys") return connection.mode === "self_host_byo";
|
||||
if (whatsappMode === "cloud") return connection.mode !== "self_host_byo";
|
||||
return false;
|
||||
};
|
||||
const baileysQr = baileysHealth?.qr || null;
|
||||
const hasTelegramConnection = connections.some(
|
||||
(connection) => connection.platform === "telegram"
|
||||
);
|
||||
const hasWhatsAppConnection = connections.some(
|
||||
(connection) => connection.platform === "whatsapp" && isConnectionInActiveMode(connection)
|
||||
);
|
||||
const hasEnabledGateway =
|
||||
telegramGatewayEnabled ||
|
||||
whatsappMode !== "disabled" ||
|
||||
slackGatewayEnabled ||
|
||||
discordGatewayEnabled;
|
||||
const isRefreshing = (platform: GatewayPlatform) => refreshingPlatform === platform;
|
||||
const refreshButtonClassName = "gap-2";
|
||||
const refreshIconClassName = (platform: GatewayPlatform) =>
|
||||
cn("mr-2 h-4 w-4", isRefreshing(platform) && "animate-spin");
|
||||
const platformLabel = (platform: string) => {
|
||||
switch (platform) {
|
||||
case "discord":
|
||||
return "Discord";
|
||||
case "slack":
|
||||
return "Slack";
|
||||
case "telegram":
|
||||
return "Telegram";
|
||||
case "whatsapp":
|
||||
return "WhatsApp";
|
||||
default:
|
||||
return platform;
|
||||
}
|
||||
};
|
||||
const connectionTitle = (connection: GatewayConnection) =>
|
||||
connection.platform === "whatsapp" && connection.mode === "self_host_byo"
|
||||
? "WhatsApp Bridge"
|
||||
: connection.workspace_name ||
|
||||
connection.display_name ||
|
||||
connection.external_username ||
|
||||
`${platformLabel(connection.platform)} connection`;
|
||||
const renderConnectionRows = (platform: GatewayConnection["platform"], emptyText: string) => {
|
||||
const platformConnections = connections.filter(
|
||||
(connection) => connection.platform === platform && isConnectionInActiveMode(connection)
|
||||
);
|
||||
|
||||
if (platformConnections.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-24 items-center justify-center text-center">
|
||||
<p className="text-xs text-muted-foreground">{emptyText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Connected accounts</p>
|
||||
{platformConnections.map((connection, index) => (
|
||||
<div key={connectionKey(connection)} className="space-y-2">
|
||||
{index > 0 ? <Separator className="bg-accent" /> : null}
|
||||
<div className="space-y-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium">{connectionTitle(connection)}</p>
|
||||
{connection.suspended_reason ? (
|
||||
<p className="mt-1 flex items-center gap-1 text-xs text-destructive">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
{connection.suspended_reason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={String(connection.search_space_id)}
|
||||
onValueChange={(value) => updateConnectionSearchSpace(connection, value)}
|
||||
disabled={searchSpaces.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-8 min-w-[180px] flex-1 text-xs">
|
||||
<SelectValue placeholder="Select search space" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{searchSpaces.map((space) => (
|
||||
<SelectItem key={space.id} value={String(space.id)}>
|
||||
{space.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{connection.state === "suspended" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
onClick={() => resume(connection)}
|
||||
>
|
||||
Resume
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2"
|
||||
onClick={() => revoke(connection)}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const renderPairingPanel = (platform: PairingPlatform) => {
|
||||
if (!pairing || pairingPlatform !== platform) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-accent bg-accent/20 p-3">
|
||||
<p className="text-xs font-medium">Pairing code</p>
|
||||
<p className="mt-2 font-mono text-lg">{pairing.code}</p>
|
||||
<a className="mt-2 block text-sm text-primary underline" href={pairing.deep_link}>
|
||||
Open {platform === "whatsapp" ? "WhatsApp" : "Telegram"} pairing link
|
||||
</a>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Expires at {new Date(pairing.expires_at).toLocaleString()}. SurfSense stores this
|
||||
channel's messages for agent memory and operational debugging.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const renderGatewaySkeletons = () => (
|
||||
<>
|
||||
{[0, 1].map((index) => (
|
||||
<Card key={index} className="h-full overflow-hidden border-accent bg-accent/20">
|
||||
<CardHeader className="space-y-3 p-4">
|
||||
<Skeleton className="h-4 w-24 bg-accent" />
|
||||
<Skeleton className="h-3 w-3/4 bg-accent" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
<Skeleton className="h-8 w-40 bg-accent" />
|
||||
<Separator className="bg-accent" />
|
||||
<Skeleton className="h-10 w-full bg-accent" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid items-stretch gap-3 sm:grid-cols-2">
|
||||
{isGatewayConfigLoading ? renderGatewaySkeletons() : null}
|
||||
|
||||
{!isGatewayConfigLoading && gatewayDisabled ? (
|
||||
<Alert className="col-span-full" variant="warning">
|
||||
<AlertTriangle aria-hidden />
|
||||
<AlertTitle>Messaging Channels coming soon</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
Soon you'll be able to connect WhatsApp, Telegram, Slack, and Discord to your
|
||||
SurfSense agent so you can ask questions, route messages to search spaces, and get
|
||||
answers from your knowledge base without leaving your chat app.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!isGatewayConfigLoading && !gatewayDisabled && !hasEnabledGateway ? (
|
||||
<Card className="col-span-full border-accent bg-accent/20">
|
||||
<CardHeader className="space-y-1.5 p-4">
|
||||
<CardTitle className="text-sm">No messaging gateways enabled</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!gatewayDisabled && telegramGatewayEnabled ? (
|
||||
<Card className="order-1 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
|
||||
<CardHeader className="space-y-1.5 p-4 pb-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">Telegram</CardTitle>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect Telegram to chat with SurfSense.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hasTelegramConnection ? null : (
|
||||
<Button size="sm" onClick={() => startPairing("telegram")}>
|
||||
Pair Telegram Chat
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={refreshButtonClassName}
|
||||
onClick={() => refreshPlatform("telegram")}
|
||||
disabled={isRefreshing("telegram")}
|
||||
>
|
||||
<RefreshCw className={refreshIconClassName("telegram")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasTelegramConnection ? null : renderPairingPanel("telegram")}
|
||||
<Separator className="bg-accent" />
|
||||
{renderConnectionRows("telegram", "No Telegram chats connected yet.")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!gatewayDisabled && slackGatewayEnabled ? (
|
||||
<Card className="order-4 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
|
||||
<CardHeader className="space-y-1.5 p-4 pb-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">Slack</CardTitle>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable the SurfSense Slack bot so teammates can mention it in Slack.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={installSlackGateway}>
|
||||
Add Slack Workspace
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={refreshButtonClassName}
|
||||
onClick={() => refreshPlatform("slack")}
|
||||
disabled={isRefreshing("slack")}
|
||||
>
|
||||
<RefreshCw className={refreshIconClassName("slack")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="bg-accent" />
|
||||
{renderConnectionRows("slack", "No Slack workspaces connected yet.")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!gatewayDisabled && discordGatewayEnabled ? (
|
||||
<Card className="order-3 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
|
||||
<CardHeader className="space-y-1.5 p-4 pb-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">Discord</CardTitle>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable the SurfSense Discord bot so teammates can mention it in Discord.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={installDiscordGateway}>
|
||||
Add Discord Server
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={refreshButtonClassName}
|
||||
onClick={() => refreshPlatform("discord")}
|
||||
disabled={isRefreshing("discord")}
|
||||
>
|
||||
<RefreshCw className={refreshIconClassName("discord")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="bg-accent" />
|
||||
{renderConnectionRows("discord", "No Discord servers connected yet.")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!gatewayDisabled && whatsappMode !== "disabled" ? (
|
||||
<Card className="order-2 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
|
||||
<CardHeader className="space-y-1.5 p-4 pb-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">WhatsApp</CardTitle>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{whatsappMode === "baileys"
|
||||
? 'Use "Message Yourself". Other chats are ignored.'
|
||||
: "Connect WhatsApp to chat with Surfsense."}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
{whatsappMode === "cloud" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hasWhatsAppConnection ? null : (
|
||||
<Button size="sm" onClick={() => startPairing("whatsapp")}>
|
||||
Pair WhatsApp
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={refreshButtonClassName}
|
||||
onClick={() => refreshPlatform("whatsapp")}
|
||||
disabled={isRefreshing("whatsapp")}
|
||||
>
|
||||
<RefreshCw className={refreshIconClassName("whatsapp")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{hasWhatsAppConnection ? null : renderPairingPanel("whatsapp")}
|
||||
</div>
|
||||
) : null}
|
||||
{whatsappMode === "baileys" ? (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={refreshButtonClassName}
|
||||
onClick={refreshBaileys}
|
||||
disabled={isRefreshing("whatsapp")}
|
||||
>
|
||||
<RefreshCw className={refreshIconClassName("whatsapp")} />
|
||||
Refresh
|
||||
</Button>
|
||||
{baileysQr ? (
|
||||
<div className="rounded-lg border border-accent bg-accent/20 p-3">
|
||||
<p className="text-sm font-medium">WhatsApp QR pairing</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Scan this QR from WhatsApp > Linked Devices > Link a Device.
|
||||
</p>
|
||||
<div className="mt-3 inline-block rounded-md bg-white p-3">
|
||||
<QRCodeSVG value={baileysQr} size={192} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{baileysHealth ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Bridge status: {baileysHealth.status}
|
||||
{typeof baileysHealth.queueDepth === "number"
|
||||
? `, queue: ${baileysHealth.queueDepth}`
|
||||
: ""}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Separator className="bg-accent" />
|
||||
{renderConnectionRows("whatsapp", "No WhatsApp chats connected yet.")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getUserAvatarColor, getUserInitials } from "@/lib/user-avatar";
|
||||
|
||||
function AvatarDisplay({
|
||||
url,
|
||||
fallback,
|
||||
bgColor,
|
||||
}: {
|
||||
url?: string;
|
||||
fallback: string;
|
||||
bgColor: string;
|
||||
}) {
|
||||
const [errorUrl, setErrorUrl] = useState<string>();
|
||||
const hasError = errorUrl === url;
|
||||
|
||||
if (url && !hasError) {
|
||||
return (
|
||||
<Image
|
||||
src={url}
|
||||
alt="Avatar"
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-16 w-16 rounded-full object-cover select-none"
|
||||
onError={() => setErrorUrl(url)}
|
||||
referrerPolicy="no-referrer"
|
||||
unoptimized
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full text-xl font-semibold text-white select-none"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{fallback}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileContent() {
|
||||
const t = useTranslations("userSettings");
|
||||
const { data: user, isLoading: isUserLoading } = useAtomValue(currentUserAtom);
|
||||
const { mutateAsync: updateUser, isPending } = useAtomValue(updateUserMutationAtom);
|
||||
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setDisplayName(user.display_name || "");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
await updateUser({
|
||||
display_name: displayName || null,
|
||||
});
|
||||
toast.success(t("profile_saved"));
|
||||
} catch {
|
||||
toast.error(t("profile_save_error"));
|
||||
}
|
||||
};
|
||||
|
||||
const hasChanges = displayName !== (user?.display_name || "");
|
||||
const avatarBgColor = getUserAvatarColor(user?.email || "");
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isUserLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="rounded-lg bg-main-panel">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="space-y-2">
|
||||
<AvatarDisplay
|
||||
url={user?.avatar_url || undefined}
|
||||
fallback={getUserInitials(user?.email || "")}
|
||||
bgColor={avatarBgColor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="display-name">{t("profile_display_name")}</Label>
|
||||
<Input
|
||||
id="display-name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
maxLength={100}
|
||||
placeholder={user?.email?.split("@")[0]}
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("profile_display_name_hint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("profile_email")}</Label>
|
||||
<Input type="email" value={user?.email || ""} disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={isPending || !hasChanges}
|
||||
className="relative gap-2 bg-white text-black hover:bg-accent hover:text-accent-foreground dark:bg-white dark:text-black"
|
||||
>
|
||||
<span className={isPending ? "opacity-0" : ""}>{t("profile_save")}</span>
|
||||
{isPending && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,409 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertTriangle, Globe, Lock, MoreHorizontal, Pencil, Sparkles, Trash2 } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createPromptMutationAtom,
|
||||
deletePromptMutationAtom,
|
||||
updatePromptMutationAtom,
|
||||
} from "@/atoms/prompts/prompts-mutation.atoms";
|
||||
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { PromptRead } from "@/contracts/types/prompts.types";
|
||||
|
||||
interface PromptFormData {
|
||||
name: string;
|
||||
prompt: string;
|
||||
mode: "transform" | "explore";
|
||||
is_public: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: PromptFormData = { name: "", prompt: "", mode: "transform", is_public: false };
|
||||
|
||||
export function PromptsContent() {
|
||||
const { data: prompts, isLoading, isError } = useAtomValue(promptsAtom);
|
||||
const { mutateAsync: createPrompt } = useAtomValue(createPromptMutationAtom);
|
||||
const { mutateAsync: updatePrompt } = useAtomValue(updatePromptMutationAtom);
|
||||
const { mutateAsync: deletePrompt } = useAtomValue(deletePromptMutationAtom);
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<PromptFormData>(EMPTY_FORM);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
||||
const [togglingPublicIds, setTogglingPublicIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!formData.name.trim() || !formData.prompt.trim()) {
|
||||
toast.error("Name and prompt are required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (editingId !== null) {
|
||||
await updatePrompt({ id: editingId, ...formData });
|
||||
} else {
|
||||
await createPrompt(formData);
|
||||
}
|
||||
setShowForm(false);
|
||||
setFormData(EMPTY_FORM);
|
||||
setEditingId(null);
|
||||
} catch {
|
||||
// toast handled by mutation atoms
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [formData, editingId, createPrompt, updatePrompt]);
|
||||
|
||||
const handleEdit = useCallback((prompt: PromptRead) => {
|
||||
setFormData({
|
||||
name: prompt.name,
|
||||
prompt: prompt.prompt,
|
||||
mode: prompt.mode as "transform" | "explore",
|
||||
is_public: prompt.is_public,
|
||||
});
|
||||
setEditingId(prompt.id);
|
||||
setShowForm(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (deleteTarget === null) return;
|
||||
try {
|
||||
await deletePrompt(deleteTarget);
|
||||
} catch {
|
||||
// toast handled by mutation atom
|
||||
} finally {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}, [deleteTarget, deletePrompt]);
|
||||
|
||||
const handleTogglePublic = useCallback(
|
||||
async (prompt: PromptRead) => {
|
||||
if (togglingPublicIds.has(prompt.id)) return;
|
||||
setTogglingPublicIds((prev) => new Set(prev).add(prompt.id));
|
||||
try {
|
||||
await updatePrompt({ id: prompt.id, is_public: !prompt.is_public });
|
||||
} catch {
|
||||
// toast handled by mutation atom
|
||||
} finally {
|
||||
setTogglingPublicIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(prompt.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[updatePrompt, togglingPublicIds]
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setShowForm(false);
|
||||
setFormData(EMPTY_FORM);
|
||||
setEditingId(null);
|
||||
}, []);
|
||||
|
||||
const list = prompts ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create prompt templates triggered with <ShortcutKbd keys={["/"]} className="ml-0" /> in
|
||||
the chat composer.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(true);
|
||||
setEditingId(null);
|
||||
setFormData(EMPTY_FORM);
|
||||
}}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={showForm}
|
||||
onOpenChange={(open) => {
|
||||
setShowForm(open);
|
||||
if (!open) {
|
||||
setFormData(EMPTY_FORM);
|
||||
setEditingId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg bg-popover text-popover-foreground">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId !== null ? "Edit prompt" : "New prompt"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create prompt templates triggered with / in the chat composer.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt-name">Name</Label>
|
||||
<Input
|
||||
id="prompt-name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
|
||||
placeholder="e.g. Fix grammar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt-template">Prompt template</Label>
|
||||
<textarea
|
||||
id="prompt-template"
|
||||
value={formData.prompt}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, prompt: e.target.value }))}
|
||||
placeholder="e.g. Fix the grammar in the following text:\n\n{selection}"
|
||||
rows={4}
|
||||
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
|
||||
{"{selection}"}
|
||||
</code>{" "}
|
||||
to insert the input text. If omitted, the text is appended automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt-mode">Mode</Label>
|
||||
<Select
|
||||
value={formData.mode}
|
||||
onValueChange={(value) =>
|
||||
setFormData((p) => ({ ...p, mode: value as "transform" | "explore" }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="prompt-mode" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="transform">
|
||||
Transform — rewrites or modifies your text
|
||||
</SelectItem>
|
||||
<SelectItem value="explore">
|
||||
Explore — answers a question about your text
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="prompt-public"
|
||||
checked={formData.is_public}
|
||||
onCheckedChange={(checked) => setFormData((p) => ({ ...p, is_public: checked }))}
|
||||
/>
|
||||
<Label htmlFor="prompt-public" className="text-sm font-normal">
|
||||
Share with community
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
className="text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="relative text-sm h-9 min-w-[96px]"
|
||||
>
|
||||
<span className={isSaving ? "opacity-0" : ""}>
|
||||
{editingId !== null ? "Update" : "Create"}
|
||||
</span>
|
||||
{isSaving && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isLoading && (
|
||||
<div className="-m-1 space-y-2 p-1">
|
||||
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||
<Card key={key} className="border-accent bg-accent/20">
|
||||
<CardContent className="p-4 flex flex-col gap-3 min-h-24">
|
||||
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
|
||||
<Skeleton className="h-3 w-full bg-accent" />
|
||||
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>Failed to load prompts</AlertTitle>
|
||||
<AlertDescription>Please try refreshing the page.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && list.length === 0 && !showForm && (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
|
||||
<Sparkles className="mx-auto size-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No prompts yet</p>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Create prompts to quickly transform or explore text with /
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && list.length > 0 && (
|
||||
<div className="-m-1 space-y-2 p-1">
|
||||
{list.map((prompt) => (
|
||||
<div
|
||||
key={prompt.id}
|
||||
className="group relative flex items-start gap-3 overflow-hidden rounded-lg border border-accent bg-accent/20 p-4 transition-all duration-200 hover:shadow-md"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{prompt.name}</span>
|
||||
<span className="rounded-md border-0 bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{prompt.mode}
|
||||
</span>
|
||||
{prompt.is_public && (
|
||||
<span className="flex items-center gap-1 rounded-md border-0 bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<Globe className="size-2.5" />
|
||||
Public
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={`mt-1 text-xs text-muted-foreground ${expandedId === prompt.id ? "whitespace-pre-wrap" : "line-clamp-2"}`}
|
||||
>
|
||||
{prompt.prompt}
|
||||
</p>
|
||||
{prompt.prompt.length > 100 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)}
|
||||
className="mt-1 h-auto cursor-pointer px-0 py-0 text-[11px] text-primary"
|
||||
>
|
||||
{expandedId === prompt.id ? "See less" : "See more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 self-center rounded-lg text-muted-foreground opacity-100 pointer-events-auto transition-opacity duration-150 hover:text-accent-foreground sm:opacity-0 sm:pointer-events-none sm:group-hover:opacity-100 sm:group-hover:pointer-events-auto"
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
<span className="sr-only">Prompt actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleTogglePublic(prompt)}
|
||||
disabled={togglingPublicIds.has(prompt.id)}
|
||||
>
|
||||
{togglingPublicIds.has(prompt.id) ? (
|
||||
<Spinner className="size-4" />
|
||||
) : prompt.is_public ? (
|
||||
<Lock className="size-4" />
|
||||
) : (
|
||||
<Globe className="size-4" />
|
||||
)}
|
||||
{prompt.is_public ? "Make private" : "Share with community"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleEdit(prompt)}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteTarget(prompt.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete prompt</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. The prompt will be permanently removed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmDelete}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { Coins, FileText, ReceiptText } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { CreditPurchase, PagePurchase, PurchaseStatus } from "@/contracts/types/stripe.types";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PurchaseKind = "pages" | "credits";
|
||||
|
||||
type UnifiedPurchase = {
|
||||
id: string;
|
||||
kind: PurchaseKind;
|
||||
created_at: string;
|
||||
status: PurchaseStatus;
|
||||
/**
|
||||
* Granted units. Interpretation depends on ``kind``:
|
||||
* - ``"pages"`` — integer number of indexed pages (legacy history).
|
||||
* - ``"credits"`` — integer micro-USD of credit (1_000_000 = $1.00).
|
||||
* The ``Granted`` column formats accordingly.
|
||||
*/
|
||||
granted: number;
|
||||
amount_total: number | null;
|
||||
currency: string | null;
|
||||
};
|
||||
|
||||
const STATUS_STYLES: Record<PurchaseStatus, { label: string; className: string }> = {
|
||||
completed: {
|
||||
label: "Completed",
|
||||
className: "bg-emerald-600 text-white border-transparent hover:bg-emerald-600",
|
||||
},
|
||||
pending: {
|
||||
label: "Pending",
|
||||
className: "bg-yellow-600 text-white border-transparent hover:bg-yellow-600",
|
||||
},
|
||||
failed: {
|
||||
label: "Failed",
|
||||
className: "bg-destructive text-white border-transparent hover:bg-destructive",
|
||||
},
|
||||
};
|
||||
|
||||
const KIND_META: Record<
|
||||
PurchaseKind,
|
||||
{ label: string; icon: React.ComponentType<{ className?: string }>; iconClass: string }
|
||||
> = {
|
||||
pages: {
|
||||
label: "Pages",
|
||||
icon: FileText,
|
||||
iconClass: "text-sky-500",
|
||||
},
|
||||
credits: {
|
||||
label: "Credits",
|
||||
icon: Coins,
|
||||
iconClass: "text-amber-500",
|
||||
},
|
||||
};
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatAmount(amount: number | null, currency: string | null): string {
|
||||
if (amount == null) return "—";
|
||||
const dollars = amount / 100;
|
||||
const code = (currency ?? "usd").toUpperCase();
|
||||
return `$${dollars.toFixed(2)} ${code}`;
|
||||
}
|
||||
|
||||
function normalizePagePurchase(p: PagePurchase): UnifiedPurchase {
|
||||
return {
|
||||
id: p.id,
|
||||
kind: "pages",
|
||||
created_at: p.created_at,
|
||||
status: p.status,
|
||||
granted: p.pages_granted,
|
||||
amount_total: p.amount_total,
|
||||
currency: p.currency,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCreditPurchase(p: CreditPurchase): UnifiedPurchase {
|
||||
return {
|
||||
id: p.id,
|
||||
kind: "credits",
|
||||
created_at: p.created_at,
|
||||
status: p.status,
|
||||
granted: p.credit_micros_granted,
|
||||
amount_total: p.amount_total,
|
||||
currency: p.currency,
|
||||
};
|
||||
}
|
||||
|
||||
function formatGranted(p: UnifiedPurchase): string {
|
||||
if (p.kind === "credits") {
|
||||
const dollars = p.granted / 1_000_000;
|
||||
// Credit packs are always whole dollars at the moment, but future
|
||||
// fractional grants (refunds, partial top-ups, auto-reload) shouldn't
|
||||
// silently round to "$0".
|
||||
if (dollars >= 1) return `$${dollars.toFixed(2)} of credit`;
|
||||
if (dollars > 0) return `$${dollars.toFixed(3)} of credit`;
|
||||
return "$0 of credit";
|
||||
}
|
||||
return p.granted.toLocaleString();
|
||||
}
|
||||
|
||||
export function PurchaseHistoryContent() {
|
||||
const results = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ["stripe-purchases"],
|
||||
queryFn: () => stripeApiService.getPagePurchases(),
|
||||
},
|
||||
{
|
||||
queryKey: ["stripe-credit-purchases"],
|
||||
queryFn: () => stripeApiService.getCreditPurchases(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const [pagesQuery, creditsQuery] = results;
|
||||
const isLoading = pagesQuery.isLoading || creditsQuery.isLoading;
|
||||
|
||||
const purchases = useMemo<UnifiedPurchase[]>(() => {
|
||||
const pagePurchases = pagesQuery.data?.purchases ?? [];
|
||||
const creditPurchases = creditsQuery.data?.purchases ?? [];
|
||||
return [
|
||||
...pagePurchases.map(normalizePagePurchase),
|
||||
...creditPurchases.map(normalizeCreditPurchase),
|
||||
].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
}, [pagesQuery.data, creditsQuery.data]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (purchases.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
||||
<ReceiptText className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">No purchases yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your credit purchases will appear here after checkout.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="text-right">Granted</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{purchases.map((p) => {
|
||||
const statusStyle = STATUS_STYLES[p.status];
|
||||
const kind = KIND_META[p.kind];
|
||||
const KindIcon = kind.icon;
|
||||
return (
|
||||
<TableRow key={`${p.kind}-${p.id}`}>
|
||||
<TableCell className="text-sm">{formatDate(p.created_at)}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<KindIcon className={cn("h-4 w-4", kind.iconClass)} />
|
||||
<span>{kind.label}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{formatGranted(p)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{formatAmount(p.amount_total, p.currency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={cn("text-[10px]", statusStyle.className)}>
|
||||
{statusStyle.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Showing your {purchases.length} most recent purchase
|
||||
{purchases.length !== 1 ? "s" : ""}.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { DesktopContent } from "../components/DesktopContent";
|
||||
|
||||
export default function Page() {
|
||||
return <DesktopContent />;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { HotkeysContent } from "../components/HotkeysContent";
|
||||
|
||||
export default function Page() {
|
||||
return <HotkeysContent />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import type React from "react";
|
||||
import { use } from "react";
|
||||
import { UserSettingsLayoutShell } from "./layout-shell";
|
||||
|
||||
export default function UserSettingsLayout({
|
||||
params,
|
||||
children,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string }>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { search_space_id } = use(params);
|
||||
|
||||
return (
|
||||
<UserSettingsLayoutShell searchSpaceId={search_space_id}>{children}</UserSettingsLayoutShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { MessagingChannelsContent } from "../components/MessagingChannelsContent";
|
||||
|
||||
export default function Page() {
|
||||
return <MessagingChannelsContent />;
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue