Merge pull request #1572 from CREDO23/add-google-search-verb

[Feat] CI : Add google_search.scrape verb + subagent
This commit is contained in:
Rohan Verma 2026-07-04 15:41:39 -07:00 committed by GitHub
commit 203215adc0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
113 changed files with 14001 additions and 0 deletions

View file

@ -29,6 +29,7 @@ SUBAGENT_TO_REQUIRED_CONNECTOR_MAP: dict[str, frozenset[str]] = {
"web_crawler": frozenset(),
"youtube": frozenset(),
"google_maps": frozenset(),
"google_search": frozenset(),
"airtable": frozenset({"AIRTABLE_CONNECTOR"}),
"calendar": frozenset({"GOOGLE_CALENDAR_CONNECTOR"}),
"clickup": frozenset({"CLICKUP_CONNECTOR"}),

View file

@ -0,0 +1 @@
"""``google_search`` builtin subagent: structured Google Search results."""

View file

@ -0,0 +1,43 @@
"""``google_search`` route: ``SurfSenseSubagentSpec`` builder for deepagents."""
from __future__ import annotations
from typing import Any
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from app.agents.chat.multi_agent_chat.subagents.shared.md_file_reader import (
read_md_file,
)
from app.agents.chat.multi_agent_chat.subagents.shared.spec import SurfSenseSubagentSpec
from app.agents.chat.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent,
)
from .tools.index import NAME, RULESET, load_tools
def build_subagent(
*,
dependencies: dict[str, Any],
model: BaseChatModel | None = None,
middleware_stack: dict[str, Any] | None = None,
mcp_tools: list[BaseTool] | None = None,
) -> SurfSenseSubagentSpec:
tools = [*load_tools(dependencies=dependencies), *(mcp_tools or [])]
description = (
read_md_file(__package__, "description").strip()
or "Searches Google and returns structured results for a query."
)
system_prompt = read_md_file(__package__, "system_prompt").strip()
return pack_subagent(
name=NAME,
description=description,
system_prompt=system_prompt,
tools=tools,
ruleset=RULESET,
dependencies=dependencies,
model=model,
middleware_stack=middleware_stack,
)

View file

@ -0,0 +1,2 @@
Google Search specialist: runs Google searches and returns structured SERP data — organic results (title, URL, description), related queries, people-also-ask, and any AI overview. Searches by plain query (optionally scoped to a country/language or restricted to a single site) or scrapes a Google Search URL as-is, and can page deeper with max_pages_per_query. Also compares fresh search results against earlier findings in this chat.
Use whenever the task is to find pages or sources on the open web via Google, discover which sites rank for a topic, or gather a list of result URLs to hand off for crawling. Triggers include "search Google for X", "find websites/pages about X", "who ranks for X", "top results for X", "find X's website", and comparisons against earlier search results in this chat. Not for reading a specific page's content (use the web crawling specialist), Google Maps places (use the Google Maps specialist), or YouTube (use the YouTube specialist).

View file

@ -0,0 +1,62 @@
You are the SurfSense Google Search sub-agent.
You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis.
<goal>
Answer the delegated question from live Google Search data gathered with your verb, comparing against earlier results already in this conversation when the task calls for it.
</goal>
<available_tools>
- `google_search_scrape`
</available_tools>
<playbook>
- Finding pages on a topic: call `google_search_scrape` with `queries`, scoping with `country_code`/`language_code` when locale matters.
- Restricting to one website: set `site` (e.g. "example.com") to only return results from that domain.
- Scraping a specific results page: pass the full Google Search URL in `queries`.
- Need more results: raise `max_pages_per_query` to page beyond the first page.
- Batch multiple search terms into one call rather than many single-term calls.
- Handing URLs off for crawling: return the organic result URLs so the supervisor can route them to the web crawling specialist.
- Comparison requests: pull the current results, compare against prior values already in this conversation's earlier tool results, and report concrete deltas (added, removed, moved up/down).
</playbook>
<tool_policy>
- Use only tools in `<available_tools>`.
- Report only results present in the tool output. Never invent titles, URLs, snippets, or rankings.
</tool_policy>
<out_of_scope>
- Do not read or extract a specific page's content — return the URLs for the web crawling specialist.
- Do not generate deliverables or perform connector mutations; return findings for the supervisor to act on.
- Google Maps places belong to the Google Maps specialist; YouTube belongs to the YouTube specialist.
</out_of_scope>
<safety>
- Report uncertainty explicitly when evidence is incomplete or conflicting.
- Never present unverified claims as facts.
</safety>
<failure_policy>
- Underspecified request — no usable query or URL — return `status=blocked` with the missing fields.
- Tool failure: return `status=error` with a concise recovery `next_step`.
- No useful evidence: return `status=blocked` with a narrower query or the scope you still need.
</failure_policy>
<output_contract>
Return **only** one JSON object (no markdown/prose):
{
"status": "success" | "partial" | "blocked" | "error",
"action_summary": string,
"evidence": {
"findings": string[],
"sources": string[],
"confidence": "high" | "medium" | "low"
},
"next_step": string | null,
"missing_fields": string[] | null,
"assumptions": string[] | null
}
<include snippet="output_contract_base"/>
Route-specific rules:
- `evidence.findings`: max 10 entries, each a single sentence stating one distinct result or delta. Do not paste raw payloads.
- `evidence.sources`: max 10 URLs, one per finding when applicable. List each URL once.
</output_contract>

View file

@ -0,0 +1,27 @@
"""``google_search`` sub-agent tools: the Google Search scrape capability verb."""
from __future__ import annotations
from typing import Any
from langchain_core.tools import BaseTool
from app.agents.chat.multi_agent_chat.shared.permissions import Ruleset
from app.capabilities.core.access.agent import build_capability_tools
from app.capabilities.google_search.scrape.definition import GOOGLE_SEARCH_SCRAPE
NAME = "google_search"
RULESET = Ruleset(origin=NAME, rules=[])
_CI_VERBS = [GOOGLE_SEARCH_SCRAPE]
def load_tools(
*, dependencies: dict[str, Any] | None = None, **kwargs: Any
) -> list[BaseTool]:
d = {**(dependencies or {}), **kwargs}
return build_capability_tools(
workspace_id=d.get("workspace_id"),
capabilities=_CI_VERBS,
)

View file

@ -18,6 +18,9 @@ from app.agents.chat.multi_agent_chat.subagents.builtins.deliverables.agent impo
from app.agents.chat.multi_agent_chat.subagents.builtins.google_maps.agent import (
build_subagent as build_google_maps_subagent,
)
from app.agents.chat.multi_agent_chat.subagents.builtins.google_search.agent import (
build_subagent as build_google_search_subagent,
)
from app.agents.chat.multi_agent_chat.subagents.builtins.knowledge_base.agent import (
build_subagent as build_knowledge_base_subagent,
)
@ -106,6 +109,7 @@ SUBAGENT_BUILDERS_BY_NAME: dict[str, SubagentBuilder] = {
"gmail": build_gmail_subagent,
"google_drive": build_google_drive_subagent,
"google_maps": build_google_maps_subagent,
"google_search": build_google_search_subagent,
"jira": build_jira_subagent,
"knowledge_base": build_knowledge_base_subagent,
"linear": build_linear_subagent,

View file

@ -0,0 +1,5 @@
"""``google_search.*`` namespace: platform-native Google Search data verbs."""
from __future__ import annotations
from app.capabilities.google_search.scrape import definition as _scrape # noqa: F401

View file

@ -0,0 +1,3 @@
"""``google_search.scrape`` verb: search terms / Google Search URLs → SERP items."""
from __future__ import annotations

View file

@ -0,0 +1,24 @@
"""``google_search.scrape`` capability registration (free — see 04-capabilities open item)."""
from __future__ import annotations
from app.capabilities.core import Capability, register_capability
from app.capabilities.google_search.scrape.executor import build_scrape_executor
from app.capabilities.google_search.scrape.schemas import ScrapeInput, ScrapeOutput
GOOGLE_SEARCH_SCRAPE = Capability(
name="google_search.scrape",
description=(
"Search Google and return structured results. Give it search terms "
"(optionally scoped by country/language or to a single site) or full "
"Google Search URLs, and it returns SERP items — organic results "
"(title, url, description), related queries, people-also-ask, and any "
"AI overview. Use max_pages_per_query to page deeper."
),
input_schema=ScrapeInput,
output_schema=ScrapeOutput,
executor=build_scrape_executor(),
billing_unit=None,
)
register_capability(GOOGLE_SEARCH_SCRAPE)

View file

@ -0,0 +1,41 @@
"""``google_search.scrape`` executor: verb input → scraper → SERP items."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from app.capabilities.core import Executor
from app.capabilities.google_search.scrape.schemas import (
MAX_PAGES_PER_QUERY,
MAX_SEARCH_QUERIES,
ScrapeInput,
ScrapeOutput,
)
from app.proprietary.platforms.google_search import (
GoogleSearchScrapeInput,
scrape_serps,
)
ScrapeFn = Callable[..., Awaitable[list[dict]]]
# Hard ceiling on SERP pages returned per call (protects the run regardless of
# how queries * max_pages_per_query multiply out).
_MAX_SERP_ITEMS = MAX_SEARCH_QUERIES * MAX_PAGES_PER_QUERY
def build_scrape_executor(scrape_fn: ScrapeFn | None = None) -> Executor:
"""Bind the executor to a scraper fn (defaults to the proprietary actor)."""
scrape_fn = scrape_fn or scrape_serps
async def execute(payload: ScrapeInput) -> ScrapeOutput:
actor_input = GoogleSearchScrapeInput(
queries="\n".join(payload.queries),
maxPagesPerQuery=payload.max_pages_per_query,
countryCode=payload.country_code,
languageCode=payload.language_code,
site=payload.site,
)
items = await scrape_fn(actor_input, limit=_MAX_SERP_ITEMS)
return ScrapeOutput(items=items)
return execute

View file

@ -0,0 +1,55 @@
"""``google_search.scrape`` I/O contracts.
A lean, agent-friendly surface over ``GoogleSearchScrapeInput``
(``app/proprietary/platforms/google_search``). The executor maps this to the
full scraper input; the scraper's ``SerpItem`` is reused verbatim as the output
element.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from app.proprietary.platforms.google_search import SerpItem
MAX_SEARCH_QUERIES = 20
"""Per-call cap on queries: bounds a synchronous request's fan-out."""
MAX_PAGES_PER_QUERY = 10
"""Deepest result-page pagination a single query will follow."""
class ScrapeInput(BaseModel):
queries: list[str] = Field(
min_length=1,
max_length=MAX_SEARCH_QUERIES,
description=(
"Search terms (e.g. 'wedding photographers denver') or full Google "
"Search URLs. Each term is searched; each URL is scraped as-is."
),
)
max_pages_per_query: int = Field(
default=1,
ge=1,
le=MAX_PAGES_PER_QUERY,
description="Result pages to fetch per query (1 = first page only).",
)
country_code: str | None = Field(
default=None,
description="Two-letter country to search from, e.g. 'us', 'fr'.",
)
language_code: str = Field(
default="",
description="Result language code, e.g. 'en', 'fr' (blank = Google default).",
)
site: str | None = Field(
default=None,
description="Restrict results to a single domain, e.g. 'example.com'.",
)
class ScrapeOutput(BaseModel):
items: list[SerpItem] = Field(
default_factory=list,
description="One item per fetched SERP page, in the scraper's emission order.",
)

View file

@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends
# Import verb namespaces for their registration side effects before the door builds.
import app.capabilities.google_maps
import app.capabilities.google_search
import app.capabilities.web
import app.capabilities.youtube # noqa: F401
from app.automations.api import router as automations_router

View file

@ -0,0 +1,11 @@
"use client";
import { useParams } from "next/navigation";
import { ArtifactsLibrary } from "@/features/artifacts-library";
export default function ArtifactsPage() {
const params = useParams();
const workspaceId = Number(params.workspace_id);
return <ArtifactsLibrary workspaceId={workspaceId} />;
}

View file

@ -0,0 +1,91 @@
"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 {
workspaceId: 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({
workspaceId,
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 workspaceId={workspaceId} />;
}
if (isLoading) {
return <AutomationDetailLoading />;
}
if (error || !automation) {
return <AutomationNotFound workspaceId={workspaceId} error={error} />;
}
return (
<>
<AutomationDetailHeader
automation={automation}
workspaceId={workspaceId}
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>
</>
);
}

View file

@ -0,0 +1,99 @@
"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>
);
}

View file

@ -0,0 +1,150 @@
"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;
workspaceId: 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,
workspaceId,
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/${workspaceId}/automations`);
}, [router, workspaceId]);
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/${workspaceId}/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/${workspaceId}/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}
workspaceId={workspaceId}
onDeleted={handleDeleted}
/>
)}
</>
);
}

View file

@ -0,0 +1,56 @@
"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>
</>
);
}

View file

@ -0,0 +1,34 @@
"use client";
import { ArrowLeft, FileWarning } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
interface AutomationNotFoundProps {
workspaceId: 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({ workspaceId, error }: AutomationNotFoundProps) {
return (
<div className="rounded-lg border border-border/60 bg-muted/20 px-6 py-12 text-center">
<FileWarning className="mx-auto h-10 w-10 text-muted-foreground" aria-hidden />
<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/${workspaceId}/automations`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to automations
</Link>
</Button>
</div>
);
}

View file

@ -0,0 +1,66 @@
"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>
);
}

View file

@ -0,0 +1,56 @@
"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>
);
}

View file

@ -0,0 +1,80 @@
"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>
);
}

View file

@ -0,0 +1,42 @@
"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>
);
}

View file

@ -0,0 +1,74 @@
"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";
}

View file

@ -0,0 +1,148 @@
"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);
}

View file

@ -0,0 +1,200 @@
"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>
);
}

View file

@ -0,0 +1,72 @@
"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>;
}

View file

@ -0,0 +1,57 @@
"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>
);
}

View file

@ -0,0 +1,123 @@
"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>
);
});

View file

@ -0,0 +1,23 @@
"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>
);
}

View file

@ -0,0 +1,386 @@
"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}
/>
)}
</>
);
}

View file

@ -0,0 +1,67 @@
"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 {
workspaceId: 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({ workspaceId, 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 workspaceId={workspaceId} />;
}
if (isLoading) {
return <AutomationDetailLoading />;
}
if (error || !automation) {
return <AutomationNotFound workspaceId={workspaceId} error={error} />;
}
return (
<AutomationBuilderForm
mode="edit"
workspaceId={workspaceId}
automation={automation}
renderModeSwitcher={(modeSwitcher) => (
<AutomationEditHeader
automation={automation}
workspaceId={workspaceId}
modeSwitcher={modeSwitcher}
/>
)}
/>
);
}

View file

@ -0,0 +1,37 @@
"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;
workspaceId: number;
modeSwitcher?: ReactNode;
}
export function AutomationEditHeader({
automation,
workspaceId,
modeSwitcher,
}: AutomationEditHeaderProps) {
const detailHref = `/dashboard/${workspaceId}/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>
);
}

View file

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

View file

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

View file

@ -0,0 +1,104 @@
"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 {
workspaceId: 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({ workspaceId }: 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 workspaceId={workspaceId} total={0} loading canCreate={false} />
<AutomationsTable
automations={[]}
workspaceId={workspaceId}
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
workspaceId={workspaceId}
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
workspaceId={workspaceId}
total={0}
loading={false}
canCreate={perms.canCreate}
showCreateCta={false}
/>
<AutomationsEmptyState workspaceId={workspaceId} canCreate={perms.canCreate} />
</>
);
}
return (
<>
<AutomationsHeader
workspaceId={workspaceId}
total={total}
loading={loading}
canCreate={perms.canCreate}
/>
<AutomationsTable
automations={automations}
workspaceId={workspaceId}
loading={loading}
canUpdate={perms.canUpdate}
canDelete={perms.canDelete}
/>
</>
);
}

View file

@ -0,0 +1,93 @@
"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;
workspaceId: 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,
workspaceId,
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}
workspaceId={workspaceId}
/>
)}
</>
);
}

View file

@ -0,0 +1,56 @@
"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;
workspaceId: 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,
workspaceId,
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/${workspaceId}/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}
workspaceId={workspaceId}
canUpdate={canUpdate}
canDelete={canDelete}
/>
</div>
</TableCell>
</TableRow>
);
}

View file

@ -0,0 +1,39 @@
"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>
);
}

View file

@ -0,0 +1,52 @@
"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 "MonFri 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>;
}

View file

@ -0,0 +1,48 @@
"use client";
import { AlarmClock } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
interface AutomationsEmptyStateProps {
workspaceId: 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({ workspaceId, canCreate }: AutomationsEmptyStateProps) {
return (
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-12 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<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/${workspaceId}/new-chat`}>Create via chat</Link>
</Button>
<Button
asChild
variant="ghost"
className="h-10 justify-start rounded-md bg-muted px-3 text-sm hover:bg-accent"
>
<Link href={`/dashboard/${workspaceId}/automations/new`}>Create manually</Link>
</Button>
</div>
) : (
<p className="mt-6 text-xs text-muted-foreground">
You don't have permission to create automations in this search space.
</p>
)}
</div>
);
}

View file

@ -0,0 +1,58 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
interface AutomationsHeaderProps {
workspaceId: 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({
workspaceId,
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/${workspaceId}/automations/new`}>Create manually</Link>
</Button>
<Button asChild size="sm">
<Link href={`/dashboard/${workspaceId}/new-chat`}>Create via chat</Link>
</Button>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,36 @@
"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>
))}
</>
);
}

View file

@ -0,0 +1,73 @@
"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[];
workspaceId: 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,
workspaceId,
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}
workspaceId={workspaceId}
canUpdate={canUpdate}
canDelete={canDelete}
/>
))
)}
</TableBody>
</Table>
</div>
);
}

View file

@ -0,0 +1,129 @@
"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>
);
}

View file

@ -0,0 +1,534 @@
"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";
workspaceId: 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,
workspaceId,
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 { workspace_id: _ignored, ...rest } = buildCreatePayload(
formForPayload,
workspaceId
);
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/${workspaceId}/automations/${automation.id}`);
} else {
const payload = buildCreatePayload(formForPayload, workspaceId);
const parsed = automationCreateRequest.safeParse(payload);
if (!parsed.success) {
setRootError(zodIssueList(parsed.error).join("; "));
return;
}
const created = await createAutomation(parsed.data);
router.push(`/dashboard/${workspaceId}/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/${workspaceId}/automations/${automation.id}`);
} else {
const parsed = automationCreateRequest.safeParse({
...jsonValue,
workspace_id: workspaceId,
});
if (!parsed.success) {
setJsonIssues(zodIssueList(parsed.error));
return;
}
const created = await createAutomation(parsed.data);
router.push(`/dashboard/${workspaceId}/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}
workspaceId={workspaceId}
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
workspaceId={workspaceId}
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,
};
}

View file

@ -0,0 +1,197 @@
"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;
workspaceId: 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,
workspaceId,
errors,
}: AutomationModelFieldsProps) {
const { llm, image, vision, isLoading } = useAutomationEligibleModels();
const rolesHref = `/dashboard/${workspaceId}/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>
);
}

View file

@ -0,0 +1,42 @@
"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>
);
}

View file

@ -0,0 +1,75 @@
"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>
);
}

View file

@ -0,0 +1,42 @@
"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>
);
}

View file

@ -0,0 +1,52 @@
"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>
);
}

View file

@ -0,0 +1,257 @@
"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 {
workspaceId: 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({
workspaceId,
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}
workspaceId={workspaceId}
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>
);
}

View file

@ -0,0 +1,276 @@
"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);
}

View file

@ -0,0 +1,135 @@
"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;
workspaceId: 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,
workspaceId,
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
workspaceId={workspaceId}
value={task.query}
mentions={task.mentions}
placeholder="What should the agent do? e.g. Summarize new docs in @Marketing since the last run."
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>
);
}

View file

@ -0,0 +1,60 @@
"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>;
workspaceId: 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, workspaceId, 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}
workspaceId={workspaceId}
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>
);
}

View file

@ -0,0 +1,75 @@
"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>
);
}

View file

@ -0,0 +1,34 @@
"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>
);
}

View file

@ -0,0 +1,88 @@
"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;
workspaceId: number;
/**
* Fired after a successful delete, before the dialog closes. The detail
* page uses this to navigate back to the list (the row simply vanishes
* 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,
workspaceId,
onDeleted,
}: DeleteAutomationDialogProps) {
const { mutateAsync: deleteAutomation } = useAtomValue(deleteAutomationMutationAtom);
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
try {
await deleteAutomation({ automationId, workspaceId });
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>
);
}

View file

@ -0,0 +1,37 @@
"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]
);
}

View file

@ -0,0 +1,49 @@
"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 {
workspaceId: 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({ workspaceId }: 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"
workspaceId={workspaceId}
renderModeSwitcher={(modeSwitcher) => (
<AutomationNewHeader workspaceId={workspaceId} modeSwitcher={modeSwitcher} />
)}
/>
);
}

View file

@ -0,0 +1,41 @@
"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 {
workspaceId: number;
modeSwitcher?: ReactNode;
}
export function AutomationNewHeader({ workspaceId, modeSwitcher }: AutomationNewHeaderProps) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<Button asChild variant="ghost" size="sm" className="-ml-2 h-auto px-2 py-1">
<Link
href={`/dashboard/${workspaceId}/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>
);
}

View file

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

View file

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

View file

@ -0,0 +1,15 @@
"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>
);
}

View file

@ -0,0 +1,16 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
export default function BuyPagesPage() {
const router = useRouter();
const params = useParams();
const workspaceId = params?.workspace_id ?? "";
useEffect(() => {
router.replace(`/dashboard/${workspaceId}/buy-more`);
}, [router, workspaceId]);
return null;
}

View file

@ -0,0 +1,16 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
export default function BuyTokensPage() {
const router = useRouter();
const params = useParams();
const workspaceId = params?.workspace_id ?? "";
useEffect(() => {
router.replace(`/dashboard/${workspaceId}/buy-more`);
}, [router, workspaceId]);
return null;
}

View file

@ -0,0 +1,220 @@
"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 { activeWorkspaceIdAtom } 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,
workspaceId,
}: {
children: React.ReactNode;
workspaceId: string;
}) {
const t = useTranslations("dashboard");
const router = useRouter();
const pathname = usePathname();
const { workspace_id } = useParams();
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom);
const setActiveWorkspaceIdState = useSetAtom(activeWorkspaceIdAtom);
const setPendingUserImageUrls = useSetAtom(pendingUserImageDataUrlsAtom);
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 = activeWorkspaceId === workspaceId;
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/${workspaceId}/onboard`);
setHasCheckedOnboarding(true);
}
}, [
isSearchSpaceReady,
loading,
accessLoading,
globalConfigsLoading,
globalConfigStatusLoading,
globalConfigStatus,
modelConnectionsLoading,
modelRoles.chat_model_id,
globalConnections,
modelConnections,
isOnboardingPage,
isOwner,
router,
workspaceId,
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 workspace_id === "string"
? workspace_id
: Array.isArray(workspace_id) && workspace_id.length > 0
? workspace_id[0]
: "";
if (!activeSeacrhSpaceId) return;
setActiveWorkspaceIdState(activeSeacrhSpaceId);
// Sync to Electron store if stored value is null (first navigation)
if (electronAPI?.getActiveSearchSpace && electronAPI.setActiveSearchSpace) {
const setActiveSearchSpace = electronAPI.setActiveSearchSpace;
electronAPI
.getActiveSearchSpace()
.then((stored: string | null) => {
if (!stored) {
setActiveSearchSpace(activeSeacrhSpaceId);
}
})
.catch(() => {});
}
}, [workspace_id, setActiveWorkspaceIdState, 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 workspaceId={workspaceId}>{children}</LayoutDataProvider>
</DocumentUploadDialogProvider>
);
}

View file

@ -0,0 +1,33 @@
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<{ workspace_id: string }> }
) {
const { workspace_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/${workspace_id}/new-chat`,
},
});
response.cookies.set(OAUTH_RESULT_COOKIE, result, {
path: "/",
maxAge: 60,
httpOnly: false,
sameSite: "lax",
});
return response;
}

View file

@ -0,0 +1,11 @@
"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>
);
}

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,100 @@
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>
);
}

View file

@ -0,0 +1,18 @@
"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 workspaceId = params?.workspace_id ?? "";
useEffect(() => {
router.replace(`/dashboard/${workspaceId}/earn-credits`);
}, [router, workspaceId]);
return null;
}

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,94 @@
"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 workspaceId = Number(params.workspace_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/${workspaceId}/new-chat`);
}
}, [shouldLeaveOnboarding, router, workspaceId]);
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
workspaceId={workspaceId}
connections={connections}
className="flex flex-col gap-6 text-left"
footerAction={
<Button
className="min-w-[112px]"
disabled={!onboardingComplete || !hasUsableChatModel}
onClick={() => router.push(`/dashboard/${workspaceId}/new-chat`)}
>
Start
</Button>
}
showAddProviderHeader={false}
/>
</div>
</div>
);
}

View file

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

View file

@ -0,0 +1,42 @@
"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 workspaceId = String(params.workspace_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&apos;re ready.
</CardContent>
<CardFooter className="flex flex-col gap-2 sm:flex-row">
<Button asChild className="w-full">
<Link href={`/dashboard/${workspaceId}/buy-more`}>Back to Pricing</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link href={`/dashboard/${workspaceId}/new-chat`}>Back to Dashboard</Link>
</Button>
</CardFooter>
</Card>
</div>
);
}

View file

@ -0,0 +1,153 @@
"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 workspaceId = String(params.workspace_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 530 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/${workspaceId}/new-chat`}>Back to Dashboard</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link href={`/dashboard/${workspaceId}/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);
}

View file

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

View file

@ -0,0 +1,145 @@
"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 {
workspaceId: string;
children: React.ReactNode;
}
export function SearchSpaceSettingsLayoutShell({
workspaceId,
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/${workspaceId}/search-space-settings/${tab}`;
return (
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:pt-6 md:flex-row">
<div className="md:w-[220px] md:shrink-0">
<h1 className="mb-4 px-1 text-2xl font-semibold tracking-tight">{t("title")}</h1>
<nav className="hidden flex-col gap-0.5 md:flex">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</nav>
<div
className="overflow-x-auto border-b border-border pb-3 md:hidden"
onScroll={handleTabScroll}
style={{
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
}}
>
<div className="flex gap-1">
{navItems.map((item) => (
<Link
key={item.value}
href={hrefFor(item.value)}
replace
scroll={false}
prefetch
className={cn(
"inline-flex h-auto shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150 focus:outline-none focus-visible:outline-none",
activeTab === item.value
? "bg-accent text-accent-foreground"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
{item.icon}
{item.label}
</Link>
))}
</div>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="hidden md:block">
<h2 className="text-lg font-semibold">{selectedLabel}</h2>
<Separator className="mt-4" />
</div>
<div className="min-w-0 pt-4 md:max-w-3xl">{children}</div>
</div>
</section>
);
}

View file

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

View file

@ -0,0 +1,6 @@
import { ModelConnectionsSettings } from "@/components/settings/model-connections-settings";
export default async function Page({ params }: { params: Promise<{ workspace_id: string }> }) {
const { workspace_id } = await params;
return <ModelConnectionsSettings workspaceId={Number(workspace_id)} />;
}

View file

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

View file

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

View file

@ -0,0 +1,6 @@
import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
export default async function Page({ params }: { params: Promise<{ workspace_id: string }> }) {
const { workspace_id } = await params;
return <PublicChatSnapshotsManager workspaceId={Number(workspace_id)} />;
}

View file

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

View file

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

View file

@ -0,0 +1,967 @@
"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 {
workspaceId: number;
}
export function TeamContent({ workspaceId }: 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 = {
workspace_id: workspaceId,
invite_id: inviteId,
};
await revokeInvite(request);
return true;
},
[revokeInvite, workspaceId]
);
const handleCreateInvite = useCallback(
async (inviteData: CreateInviteRequest["data"]) => {
const request: CreateInviteRequest = {
workspace_id: workspaceId,
data: inviteData,
};
return await createInvite(request);
},
[createInvite, workspaceId]
);
const handleUpdateMember = useCallback(
async (membershipId: number, roleId: number | null): Promise<Membership> => {
const request: UpdateMembershipRequest = {
workspace_id: workspaceId,
membership_id: membershipId,
data: { role_id: roleId },
};
return (await updateMember(request)) as Membership;
},
[updateMember, workspaceId]
);
const handleRemoveMember = useCallback(
async (membershipId: number) => {
const request: DeleteMembershipRequest = {
workspace_id: workspaceId,
membership_id: membershipId,
};
await deleteMember(request);
return true;
},
[deleteMember, workspaceId]
);
const { data: roles = [], isLoading: rolesLoading } = useQuery({
queryKey: cacheKeys.roles.all(workspaceId.toString()),
queryFn: () => rolesApiService.getRoles({ workspace_id: workspaceId }),
enabled: !!workspaceId,
});
const { data: invites = [], isLoading: invitesLoading } = useQuery({
queryKey: cacheKeys.invites.all(workspaceId.toString()),
queryFn: () => invitesApiService.getInvites({ workspace_id: workspaceId }),
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(workspaceId, members.length, ownerCount);
}
}, [members, membersLoading, workspaceId]);
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}
workspaceId={workspaceId}
/>
)}
{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}`}
workspaceId={workspaceId}
member={member}
roles={roles}
canManageRoles={canManageRoles}
canRemove={canRemove}
onUpdateRole={handleUpdateMember}
onRemoveMember={handleRemoveMember}
/>
))}
{paginatedMembers.map((member) => (
<MemberRow
key={`member-${member.id}`}
workspaceId={workspaceId}
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({
workspaceId,
member,
roles,
canManageRoles,
canRemove,
onUpdateRole,
onRemoveMember,
}: {
workspaceId: 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/${workspaceId}/search-space-settings/team-roles`)
}
>
Manage Roles
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<span className="text-sm text-foreground">{roleName}</span>
)}
</TableCell>
</TableRow>
);
}
function CreateInviteDialog({
roles,
onCreateInvite,
workspaceId,
}: {
roles: Role[];
onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>;
workspaceId: 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(workspaceId, {
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>
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,487 @@
"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 { activeWorkspaceIdAtom } 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(workspaceId: number) {
return ["agent-permission-rules", workspaceId] 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 workspaceIdRaw = useAtomValue(activeWorkspaceIdAtom);
const workspaceId = workspaceIdRaw ? Number(workspaceIdRaw) : 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: workspaceId
? permissionRulesQueryKey(workspaceId)
: ["agent-permission-rules", "none"],
queryFn: () => agentPermissionsApiService.list(workspaceId as number),
enabled: !!workspaceId && featureEnabled,
staleTime: 60 * 1000,
});
const createMutation = useMutation({
mutationFn: (payload: AgentPermissionRuleCreate) =>
agentPermissionsApiService.create(workspaceId as number, payload),
onSuccess: () => {
toast.success("Rule created.");
queryClient.invalidateQueries({
queryKey: permissionRulesQueryKey(workspaceId 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(workspaceId as number, params.ruleId, {
action: params.action,
pattern: params.pattern,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: permissionRulesQueryKey(workspaceId as number),
});
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : "Failed to update rule.");
},
});
const deleteMutation = useMutation({
mutationFn: (ruleId: number) =>
agentPermissionsApiService.remove(workspaceId as number, ruleId),
onSuccess: () => {
toast.success("Rule deleted.");
queryClient.invalidateQueries({
queryKey: permissionRulesQueryKey(workspaceId 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 (!workspaceId) {
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>
);
}

View file

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

View file

@ -0,0 +1,278 @@
"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>
);
}

View file

@ -0,0 +1,135 @@
"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>
);
}

View file

@ -0,0 +1,231 @@
"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>
);
}

View file

@ -0,0 +1,204 @@
"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>
);
}

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