diff --git a/surfsense_backend/app/capabilities/google_search/scrape/__init__.py b/surfsense_backend/app/capabilities/google_search/scrape/__init__.py
new file mode 100644
index 000000000..c4b1ba5d1
--- /dev/null
+++ b/surfsense_backend/app/capabilities/google_search/scrape/__init__.py
@@ -0,0 +1,3 @@
+"""``google_search.scrape`` verb: search terms / Google Search URLs → SERP items."""
+
+from __future__ import annotations
diff --git a/surfsense_backend/app/capabilities/google_search/scrape/schemas.py b/surfsense_backend/app/capabilities/google_search/scrape/schemas.py
new file mode 100644
index 000000000..5fdb52e24
--- /dev/null
+++ b/surfsense_backend/app/capabilities/google_search/scrape/schemas.py
@@ -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.",
+ )
diff --git a/surfsense_web/app/dashboard/[workspace_id]/artifacts/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/artifacts/page.tsx
new file mode 100644
index 000000000..a9fdb5843
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/artifacts/page.tsx
@@ -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 ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/automation-detail-content.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/automation-detail-content.tsx
new file mode 100644
index 000000000..d4f0962f4
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/automation-detail-content.tsx
@@ -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 ;
+ }
+
+ if (!perms.canRead) {
+ return (
+
+
+
Access denied
+
+ You don't have permission to view automations in this search space.
+
+
+ );
+ }
+
+ if (!validId) {
+ return ;
+ }
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error || !automation) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-definition-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-definition-section.tsx
new file mode 100644
index 000000000..ab6168305
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-definition-section.tsx
@@ -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 (
+
+
+ Automation details
+
+
+ {definition.goal && (
+
+ {definition.goal}
+
+ )}
+
+ {hasTags && (
+
+
+ {definition.metadata.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ )}
+
+ {hasInputs && (
+
+ {definition.inputs && }
+
+ )}
+
+
+ Plan
+
+ {stepCount}
+
+ }
+ >
+
+ {definition.plan.map((step, idx) => (
+
+ ))}
+
+
+
+ {advancedOpen ? "Hide advanced options" : "Advanced options"}
+
+
+
+
+ Execution defaults
+
+
+
+
+
+
+
+
+ );
+}
+
+function Field({ label, children }: { label: React.ReactNode; children: React.ReactNode }) {
+ return (
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-detail-header.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-detail-header.tsx
new file mode 100644
index 000000000..945630ba2
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-detail-header.tsx
@@ -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 (
+ <>
+
+
+
+
+ Back to automations
+
+
+
+
+
+
+ {automation.name}
+
+ {automation.description && (
+
{automation.description}
+ )}
+
+
+
+ {canUpdate && (
+
+
+
+ Edit
+
+
+ )}
+ {canToggle && (
+
+
+
+ {pauseLabel}
+
+ {updating && (
+
+ )}
+
+ )}
+ {canDelete && (
+
setDeleteOpen(true)}
+ className="justify-start rounded-md bg-muted px-3 hover:bg-accent"
+ >
+
+ Delete
+
+ )}
+
+
+
+
+ {canDelete && (
+
+ )}
+ >
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-detail-loading.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-detail-loading.tsx
new file mode 100644
index 000000000..0d6ba3110
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-detail-loading.tsx
@@ -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 (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-not-found.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-not-found.tsx
new file mode 100644
index 000000000..23fe823fe
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-not-found.tsx
@@ -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 (
+
+
+
Automation not found
+
+ This automation doesn't exist or you don't have access to it.
+ {error?.message ? ` (${error.message})` : null}
+
+
+
+
+ Back to automations
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-runs-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-runs-section.tsx
new file mode 100644
index 000000000..bd683fe57
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-runs-section.tsx
@@ -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 (
+
+
+
+
+ Recent runs
+
+
+ Most recent first. Click a row to inspect step results, output and artifacts.
+
+
+ {!isLoading && !error && data && (
+ {data.total} total
+ )}
+
+
+ {isLoading ? (
+
+ ) : error ? (
+
+ Couldn't load runs{error.message ? `: ${error.message}` : "."}
+
+ ) : runs.length === 0 ? (
+
+
+
No runs yet
+
+ This automation hasn't fired. Once a trigger fires (or you invoke it manually), runs
+ will appear here.
+
+
+ ) : (
+
+ {runs.map((run) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-triggers-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-triggers-section.tsx
new file mode 100644
index 000000000..abe739dcc
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/automation-triggers-section.tsx
@@ -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 (
+
+
+ Triggers
+ When this automation runs
+
+
+ {triggers.length === 0 ? (
+
+
+
No triggers attached
+
+ This automation can still be invoked, but nothing will fire it on its own.
+
+
+ ) : (
+
+ {triggers.map((trigger) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/delete-trigger-dialog.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/delete-trigger-dialog.tsx
new file mode 100644
index 000000000..71e905724
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/delete-trigger-dialog.tsx
@@ -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 (
+
+
+
+ Remove this trigger?
+
+ {triggerLabel} will be detached.
+ The automation itself stays, but it won't fire on this trigger anymore.
+
+
+
+ Cancel
+
+ {submitting ? (
+
+
+ Removing…
+
+ ) : (
+ "Remove"
+ )}
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/execution-summary.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/execution-summary.tsx
new file mode 100644
index 000000000..82abce173
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/execution-summary.tsx
@@ -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 (
+
+
+
+
+
+ {execution.on_failure.length > 0 && (
+
+ )}
+
+ );
+}
+
+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 (
+
+
{label}
+ {value}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/inputs-schema-preview.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/inputs-schema-preview.tsx
new file mode 100644
index 000000000..dce6ac4a7
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/inputs-schema-preview.tsx
@@ -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 No extra inputs are required.
;
+ }
+
+ return (
+
+ {fields.map((field) => (
+
+
+
{field.name}
+ {field.description ? (
+
{field.description}
+ ) : null}
+
+
+ {field.type}
+ {field.required ? " · required" : ""}
+
+
+ ))}
+
+ );
+}
+
+function getInputFields(schema: Record): {
+ 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).map(([name, value]) => {
+ const field = value && typeof value === "object" && !Array.isArray(value) ? value : {};
+ return {
+ name,
+ type: formatType((field as Record).type),
+ description:
+ typeof (field as Record).description === "string"
+ ? ((field as Record).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";
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/plan-step-card.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/plan-step-card.tsx
new file mode 100644
index 000000000..7505ef49b
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/plan-step-card.tsx
@@ -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 (
+
+
+
+ {index + 1}
+
+
+
{title}
+ {details.length > 0 ? (
+
+ {details.map((detail) => (
+
+ ))}
+
+ ) : null}
+
+
+
+ );
+}
+
+function DefRow({ label, value }: { label: string; value: string }) {
+ return (
+
+
{label}:
+ {value}
+
+ );
+}
+
+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, key: string): string | null {
+ const value = params[key];
+ return typeof value === "string" && value.trim() ? value : null;
+}
+
+function summarizeMentions(params: Record): 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 {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
+
+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);
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-details-panel.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-details-panel.tsx
new file mode 100644
index 000000000..ab82589dc
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-details-panel.tsx
@@ -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 (
+
+ {runError ?
: null}
+
+ {hasOutput ? (
+
+ ) : null}
+
+
+ {liveSteps.length === 0 ? (
+
+ {isTerminal ? "No steps recorded." : "Waiting for first step…"}
+
+ ) : (
+
+ {liveSteps.map((step, index) => (
+
+ ))}
+
+ )}
+
+
+ {heavyLoading ? (
+
+ ) : heavyError ? (
+
+ Couldn't load run details{error?.message ? `: ${error.message}` : "."}
+
+ ) : hasDiagnostics ? (
+ <>
+
+ {run && run.artifacts.length > 0 ? (
+
+ ) : null}
+ {hasInputs ? (
+
+ ) : null}
+ >
+ ) : null}
+
+ );
+}
+
+/**
+ * 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 }) {
+ 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 (
+
+ {message ? (
+
+
+ {type}
+ {message}
+
+ ) : null}
+
+
+
+
+ {rawOpen ? "Hide raw" : "View raw"}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function Section({
+ icon: Icon,
+ label,
+ tone = "default",
+ children,
+}: {
+ icon: typeof AlertCircle;
+ label: string;
+ tone?: "default" | "destructive";
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+function JsonBlock({ value }: { value: unknown }) {
+ return (
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-row.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-row.tsx
new file mode 100644
index 000000000..b48230e3f
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-row.tsx
@@ -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 (
+
+
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}
+ >
+
+ {open ? (
+
+ ) : (
+
+ )}
+
+ {startedLabel}
+
+
+ {duration && {duration} }
+
+
+
+
+ {open && (
+
+ )}
+
+ );
+}
+
+function TriggerSource({ triggerId }: { triggerId: number | null }) {
+ if (triggerId == null) {
+ return (
+
+
+ Manual
+
+ );
+ }
+ return via trigger #{triggerId} ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-status-badge.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-status-badge.tsx
new file mode 100644
index 000000000..e5532a500
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-status-badge.tsx
@@ -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 (
+
+
+ {label}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-step-result-card.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-step-result-card.tsx
new file mode 100644
index 000000000..eef572300
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/run-step-result-card.tsx
@@ -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["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 (
+
+
+ {meta.label}
+
+ );
+}
+
+/**
+ * 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 (
+
+
+
+
+ {step.action}
+ {step.step_id}
+
+
+
+ {hasMeta ? (
+
+ {duration ? {duration} : null}
+ {attempts > 1 ? {attempts} attempts : null}
+
+ ) : null}
+
+
+
+ {errorMessage ? (
+
+
+ {step.error?.type ?? "Error"}
+ {errorMessage}
+
+ ) : null}
+
+ {finalMessage ? (
+
+
+
+ ) : null}
+
+
+
+
+
+ {rawOpen ? "Hide raw" : "View raw"}
+
+
+
+
+
+
+
+
+
+
+ );
+});
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/runs-loading.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/runs-loading.tsx
new file mode 100644
index 000000000..61ce25e32
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/runs-loading.tsx
@@ -0,0 +1,23 @@
+"use client";
+import { Skeleton } from "@/components/ui/skeleton";
+
+const ROW_KEYS = ["a", "b", "c"] as const;
+
+export function RunsLoading() {
+ return (
+
+ {ROW_KEYS.map((key) => (
+
+ ))}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/trigger-card.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/trigger-card.tsx
new file mode 100644
index 000000000..de156a09c
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/components/trigger-card.tsx
@@ -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 | "custom";
+
+interface TriggerDraft {
+ frequency: SimpleFrequency;
+ hour: number;
+ minute: number;
+ timezone: string;
+ cron: string;
+}
+
+const SIMPLE_FREQUENCIES = new Set(["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(() => draftFromTrigger(trigger));
+ const [issues, setIssues] = useState([]);
+
+ 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 (
+ <>
+
+
+
{human}
+
+
+ {canUpdate && (
+
+ )}
+ {showActions && (
+
+
+
+
+
+
+
+ {canUpdate && !isEditing && (
+
+
+ Edit
+
+ )}
+ {canDelete && (
+ setDeleteOpen(true)}>
+
+ Delete
+
+ )}
+
+
+ )}
+
+
+
+ {!isEditing && trigger.next_fire_at ? (
+
+
+ Next fire:
+
+
+ {formatRelativeFutureDate(trigger.next_fire_at)}
+
+
+ ) : null}
+
+ {isEditing ? (
+
+
+
+
+ Runs
+
+
+ setDraft((prev) => ({ ...prev, frequency: value as SimpleFrequency }))
+ }
+ >
+
+
+
+
+ Every hour
+ Daily
+ Weekdays
+ Custom cron
+
+
+
+
+ {draft.frequency === "hourly" ? (
+
+
+ At minute
+
+
+ setDraft((prev) => ({
+ ...prev,
+ minute: clampInt(event.target.value, 0, 59),
+ }))
+ }
+ />
+
+ ) : draft.frequency !== "custom" ? (
+
+
+ Time
+
+ {
+ const [hour, minute] = event.target.value.split(":");
+ setDraft((prev) => ({
+ ...prev,
+ hour: clampInt(hour, 0, 23),
+ minute: clampInt(minute, 0, 59),
+ }));
+ }}
+ />
+
+ ) : (
+
+
+ Schedule expression
+
+
+ setDraft((prev) => ({ ...prev, cron: event.target.value }))
+ }
+ />
+
+ )}
+
+
+
Timezone
+
setDraft((prev) => ({ ...prev, timezone }))}
+ />
+
+
+
+ {issues.length > 0 && (
+
+
+
+ {issues.length === 1 ? "1 issue" : `${issues.length} issues`}
+
+
+
+ {issues.map((issue) => (
+ {issue}
+ ))}
+
+
+
+ )}
+
+
+
+ Cancel
+
+
+ Save
+ {updating ? (
+
+ ) : null}
+
+
+
+ ) : null}
+
+
+ {canDelete && (
+
+ )}
+ >
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/automation-edit-content.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/automation-edit-content.tsx
new file mode 100644
index 000000000..77e26e51a
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/automation-edit-content.tsx
@@ -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 ;
+ }
+
+ if (!perms.canUpdate) {
+ return (
+
+
+
Access denied
+
+ You don't have permission to edit automations in this search space.
+
+
+ );
+ }
+
+ if (!validId) {
+ return ;
+ }
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error || !automation) {
+ return ;
+ }
+
+ return (
+ (
+
+ )}
+ />
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx
new file mode 100644
index 000000000..6951803df
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx
@@ -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 (
+
+
+
+
+ Back to automation
+
+
+
+
+ Edit automation
+
+ {modeSwitcher ?
{modeSwitcher}
: null}
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/page.tsx
new file mode 100644
index 000000000..728e018d0
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/edit/page.tsx
@@ -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 (
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/page.tsx
new file mode 100644
index 000000000..8f6024a7b
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/[automation_id]/page.tsx
@@ -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 (
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/automations-content.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/automations-content.tsx
new file mode 100644
index 000000000..9e0fa81c6
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/automations-content.tsx
@@ -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 (
+ <>
+
+
+ >
+ );
+ }
+
+ if (!perms.canRead) {
+ return (
+
+
+
Access denied
+
+ You don't have permission to view automations in this search space.
+
+
+ );
+ }
+
+ if (error) {
+ return (
+ <>
+
+
+
+ Couldn't load automations {error.message}
+
+ >
+ );
+ }
+
+ if (!loading && automations.length === 0) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-row-actions.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-row-actions.tsx
new file mode 100644
index 000000000..617441dd5
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-row-actions.tsx
@@ -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 (
+ <>
+
+
+
+
+
+
+
+ {canToggle && (
+
+
+ {pauseLabel}
+
+ )}
+ {canDelete && (
+ setDeleteOpen(true)}>
+
+ Delete
+
+ )}
+
+
+
+ {canDelete && (
+
+ )}
+ >
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-row.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-row.tsx
new file mode 100644
index 000000000..a7c831ed1
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-row.tsx
@@ -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 (
+
+
+
+ {automation.name}
+
+
+
+
+
+
+ {formatRelativeDate(automation.updated_at)}
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-status-badge.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-status-badge.tsx
new file mode 100644
index 000000000..c3cab1dc1
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-status-badge.tsx
@@ -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 = {
+ 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 (
+
+ {label}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-triggers-summary.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-triggers-summary.tsx
new file mode 100644
index 000000000..270a1f844
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automation-triggers-summary.tsx
@@ -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 → "Mon–Fri at 09:00 · UTC" + disabled badge if off
+ * - >1 → "N triggers"
+ *
+ * The detail page renders the full per-trigger editor.
+ */
+export function AutomationTriggersSummary({ triggers }: AutomationTriggersSummaryProps) {
+ if (triggers.length === 0) {
+ return No triggers ;
+ }
+
+ if (triggers.length > 1) {
+ return {triggers.length} triggers ;
+ }
+
+ 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 (
+
+
+ {human}
+ · {tz}
+ {!trigger.enabled && (
+
+
+ Off
+
+ )}
+
+ );
+ }
+
+ return {trigger.type} ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-empty-state.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-empty-state.tsx
new file mode 100644
index 000000000..c689d85bb
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-empty-state.tsx
@@ -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 (
+
+
+
No automations yet
+
+ Automations let SurfSense run agent tasks on a schedule. Describe what you want in chat and
+ SurfSense drafts the automation for your approval.
+
+ {canCreate ? (
+
+
+ Create via chat
+
+
+ Create manually
+
+
+ ) : (
+
+ You don't have permission to create automations in this search space.
+
+ )}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-header.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-header.tsx
new file mode 100644
index 000000000..6e284709a
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-header.tsx
@@ -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 (
+
+
+
Automations
+ {!loading && (
+
+ {total} {total === 1 ? "automation" : "automations"}
+
+ )}
+
+ {canCreate && showCreateCta && (
+
+
+ Create manually
+
+
+ Create via chat
+
+
+ )}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx
new file mode 100644
index 000000000..1156be3f6
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx
@@ -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) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+ >
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-table.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-table.tsx
new file mode 100644
index 000000000..09a1156a1
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-table.tsx
@@ -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 (
+
+
+
+
+
+
+
+ Name
+
+
+
+
+
+ Status
+
+
+
+
+
+ Updated
+
+
+
+ Actions
+
+
+
+
+ {loading ? (
+
+ ) : (
+ automations.map((automation) => (
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/advanced-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/advanced-section.tsx
new file mode 100644
index 000000000..110de57f6
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/advanced-section.tsx
@@ -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) => 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 (
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-builder-form.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-builder-form.tsx
new file mode 100644
index 000000000..e6fb96605
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-builder-form.tsx
@@ -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 {
+ const out: Record = {};
+ 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(initial.mode);
+ const [form, setForm] = useState(initial.form);
+ const [errors, setErrors] = useState>({});
+ const [rootError, setRootError] = useState(null);
+
+ const [jsonValue, setJsonValue] = useState>(() =>
+ initial.mode === "json" ? jsonFromAutomation(automation) : {}
+ );
+ const [jsonIssues, setJsonIssues] = useState([]);
+ const [jsonNotice, setJsonNotice] = useState(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(
+ () => ({
+ 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(
+ () => ({ ...form, models: resolvedModels }),
+ [form, resolvedModels]
+ );
+
+ function patchForm(patch: Partial) {
+ setForm((prev) => ({ ...prev, ...patch }));
+ }
+
+ function jsonFromCurrentForm(): Record {
+ 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 | 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 = (
+ {
+ if (value === activeMode) return;
+ if (value === "form") switchToForm();
+ else if (value === "json") switchToJson();
+ }}
+ >
+
+
+
+ Form
+
+
+
+ Edit as JSON
+
+
+
+ );
+
+ return (
+
+ {renderModeSwitcher ? (
+ renderModeSwitcher(modeSwitcher)
+ ) : (
+
{modeSwitcher}
+ )}
+
+ {activeMode === "json" ? (
+
+ ) : (
+
+
+
+
+
+
+
+ Tasks
+
+
+ patchForm({ tasks })}
+ />
+ patchForm({ unattended })}
+ />
+
+
+
+
+
+ Schedule
+
+
+ patchForm({ schedule })}
+ onTimezoneChange={(timezone) => patchForm({ timezone })}
+ />
+
+
+
+
+
+ Models
+
+
+ patchForm({ models: { ...form.models, ...patch } })}
+ />
+
+
+
+
+
+ Settings
+
+
+
+ patchForm({ execution: { ...form.execution, ...patch } })
+ }
+ onTagsChange={(tags) => patchForm({ tags })}
+ />
+
+
+
+
+
+
+
+
+ Summary
+
+
+
+
+
+
+
+ )}
+
+ {rootError && (
+
+
+ {rootError}
+
+ )}
+
+
+ {submitBlocked ? (
+
+
+ {/* aria-disabled keeps the button focusable so the tooltip is
+ reachable by hover and keyboard; onClick is a no-op. */}
+ event.preventDefault()}
+ >
+ {submitLabel}
+
+
+ {effectiveDisabledReason}
+
+ ) : (
+ (activeMode === "json" ? submitJson() : submitForm())}
+ >
+ {submitLabel}
+ {submitting && }
+
+ )}
+
+
+ );
+}
+
+function extractTriggers(raw: unknown): HydratableTrigger[] {
+ if (!Array.isArray(raw)) return [];
+ return raw.map((entry) => {
+ const obj = entry && typeof entry === "object" ? (entry as Record) : {};
+ return {
+ type: typeof obj.type === "string" ? obj.type : "",
+ params:
+ obj.params && typeof obj.params === "object" ? (obj.params as Record) : {},
+ };
+ });
+}
+
+function zodIssueList(error: z.ZodError): string[] {
+ return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`);
+}
+
+function jsonFromAutomation(automation: Automation | undefined): Record {
+ if (!automation) return {};
+ return {
+ name: automation.name,
+ description: automation.description ?? null,
+ status: automation.status,
+ definition: automation.definition,
+ };
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-model-fields.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-model-fields.tsx
new file mode 100644
index 000000000..7f7ca3138
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/automation-model-fields.tsx
@@ -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) => void;
+ workspaceId: number;
+ errors?: Partial>;
+}
+
+/**
+ * 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 (
+
+ onChange({ chatModelId: id })}
+ />
+ onChange({ imageConfigId: id })}
+ />
+ onChange({ visionConfigId: id })}
+ />
+
+ );
+}
+
+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 (
+
+
+
+ );
+ }
+
+ if (kind.options.length === 0) {
+ return (
+
+
+
+ No eligible models
+
+ Use a premium model or your own (BYOK) model in{" "}
+
+ role settings
+
+
+
+
+ );
+ }
+
+ 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 (
+
+ onChange(Number(v))}>
+
+ {selected ? (
+
+ {getProviderIcon(selected.provider)}
+ {selected.name}
+
+ ) : (
+
+ )}
+
+
+ {premium.length > 0 ? (
+
+ Premium
+ {premium.map((option) => (
+
+ ))}
+
+ ) : null}
+ {premium.length > 0 && byok.length > 0 ? : null}
+ {byok.length > 0 ? (
+
+ Your models
+ {byok.map((option) => (
+
+ ))}
+
+ ) : null}
+
+
+
+ );
+});
+
+function ModelOption({
+ option,
+ badge,
+}: {
+ option: EligibleModelOption;
+ badge: "Premium" | "BYOK";
+}) {
+ return (
+
+
+ {getProviderIcon(option.provider)}
+ {option.name}
+ {badge}
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/basics-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/basics-section.tsx
new file mode 100644
index 000000000..fdc9f4526
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/basics-section.tsx
@@ -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;
+ onChange: (patch: { name?: string; description?: string | null }) => void;
+}
+
+export function BasicsSection({ name, description, errors, onChange }: BasicsSectionProps) {
+ return (
+
+
+ onChange({ name: e.target.value })}
+ />
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/builder-summary.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/builder-summary.tsx
new file mode 100644
index 000000000..4ba9e4182
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/builder-summary.tsx
@@ -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 (
+
+
+
+
+
+
+
+ {scheduleDescription ? (
+
+ {scheduleDescription}
+
+ {form.timezone}
+
+ ) : (
+ No schedule — won't run automatically
+ )}
+
+
+
+
+ {visibleTasks.map((task, index) => (
+
+ {index + 1}.
+
+ {task.query.trim() || "No instructions yet"}
+
+
+ ))}
+ {hiddenTaskCount > 0 && (
+ +{hiddenTaskCount} more tasks
+ )}
+
+
+
+
+ {form.unattended ? "Runs without approval prompts" : "Approval prompts are rejected"}
+
+
+
+ );
+}
+
+function SummaryRow({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/form-field.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/form-field.tsx
new file mode 100644
index 000000000..222efd9c6
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/form-field.tsx
@@ -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 (
+
+ {label && (
+
+ {label}
+ {required && * }
+
+ )}
+ {children}
+ {error ? (
+
+
+ {error}
+
+ ) : hint ? (
+
{hint}
+ ) : null}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/json-mode-panel.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/json-mode-panel.tsx
new file mode 100644
index 000000000..412533d36
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/json-mode-panel.tsx
@@ -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;
+ issues: string[];
+ notice?: string;
+ onChange: (next: Record) => 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 (
+
+ {notice && (
+
+
+ {notice}
+
+ )}
+
+
onChange(next as Record)}
+ collapsed={false}
+ className="max-h-144 overflow-auto rounded-md border border-accent bg-accent/20"
+ />
+
+ {issues.length > 0 && (
+
+
+ {issues.length === 1 ? "1 issue" : `${issues.length} issues`}
+
+
+ {issues.map((issue) => (
+ {issue}
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/mention-task-input.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/mention-task-input.tsx
new file mode 100644
index 000000000..614cfd7eb
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/mention-task-input.tsx
@@ -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(null);
+ const pickerRef = useRef(null);
+
+ const [showPopover, setShowPopover] = useState(false);
+ const [mentionQuery, setMentionQuery] = useState("");
+ const [anchorPoint, setAnchorPoint] = useState(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 (
+
+
+ {anchorPoint ? (
+ <>
+
+
+
+
+ >
+ ) : null}
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/schedule-section.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/schedule-section.tsx
new file mode 100644
index 000000000..810984acd
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/schedule-section.tsx
@@ -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;
+ 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 (
+
+
+
No schedule
+
+ This automation won't run automatically until you add one.
+
+
onScheduleChange({ mode: "preset", model: { ...DEFAULT_SCHEDULE } })}
+ >
+
+ Add a schedule
+
+
+ );
+ }
+
+ const cron = scheduleToCron(schedule);
+ const label = describeCron(cron);
+
+ return (
+
+
+
+
+ {label}
+
+ {timezone}
+
+
onScheduleChange(null)}
+ >
+
+
+
+
+ {schedule.mode === "preset" ? (
+
onScheduleChange({ mode: "preset", model })}
+ onSwitchToCron={() => onScheduleChange({ mode: "cron", cron: toCron(schedule.model) })}
+ />
+ ) : (
+ onScheduleChange({ mode: "cron", cron: value })}
+ onSwitchToPreset={() =>
+ onScheduleChange({
+ mode: "preset",
+ model: fromCron(schedule.cron) ?? { ...DEFAULT_SCHEDULE },
+ })
+ }
+ />
+ )}
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+ onChange({ ...model, frequency: value as ScheduleFrequency })}
+ >
+
+
+
+
+ {FREQUENCY_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ {model.frequency === "hourly" ? (
+
+ onChange({ ...model, minute: clampInt(e.target.value, 0, 59) })}
+ />
+
+ ) : (
+
+ {
+ const [h, m] = e.target.value.split(":");
+ onChange({
+ ...model,
+ hour: clampInt(h, 0, 23),
+ minute: clampInt(m, 0, 59),
+ });
+ }}
+ />
+
+ )}
+
+
+ {model.frequency === "weekly" && (
+
+
+ {WEEKDAY_OPTIONS.map((day) => {
+ const active = model.daysOfWeek.includes(day.value);
+ return (
+
+ 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}
+
+ );
+ })}
+
+
+ )}
+
+ {model.frequency === "monthly" && (
+
+ onChange({ ...model, dayOfMonth: clampInt(e.target.value, 1, 31) })}
+ className="w-24"
+ />
+
+ )}
+
+
+ Advanced: enter a schedule expression
+
+
+ );
+}
+
+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 (
+
+
+ onChange(e.target.value)}
+ />
+
+ {label && label !== trimmed &&
Runs: {label}
}
+
+ Use the simple picker
+
+
+ );
+}
+
+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);
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/task-item.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/task-item.tsx
new file mode 100644
index 000000000..ca43aae5d
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/task-item.tsx
@@ -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) => 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 (
+
+
+
+
+ {index + 1}
+
+ Task {index + 1}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ onChange({ query, mentions })}
+ />
+
+
+
+
+
+
+ Advanced
+
+
+
+
+
+
+ onChange({ maxRetries: parseOptionalInt(e.target.value) })}
+ />
+
+
+ onChange({ timeoutSeconds: parseOptionalInt(e.target.value) })}
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/task-list.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/task-list.tsx
new file mode 100644
index 000000000..e72c790fd
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/task-list.tsx
@@ -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;
+ 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) {
+ 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 (
+
+ {tasks.map((task, index) => (
+
updateAt(index, patch)}
+ onMoveUp={() => move(index, -1)}
+ onMoveDown={() => move(index, 1)}
+ onRemove={() => removeAt(index)}
+ />
+ ))}
+
+ {errors.tasks && {errors.tasks}
}
+
+ onChange([...tasks, emptyTask()])}>
+
+ Add task
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/timezone-combobox.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/timezone-combobox.tsx
new file mode 100644
index 000000000..ed0808bb3
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/timezone-combobox.tsx
@@ -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 (
+
+
+
+ {value || "Select timezone"}
+
+
+
+
+
+
+
+ No timezone found.
+
+ {timezones.map((tz) => (
+ {
+ onChange(tz);
+ setOpen(false);
+ }}
+ >
+
+ {tz}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/unattended-toggle.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/unattended-toggle.tsx
new file mode 100644
index 000000000..861f22204
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/builder/unattended-toggle.tsx
@@ -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 (
+
+
+
+
+ Run without asking for approvals
+
+
+
+ Tasks run automatically without asking for confirmation
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/delete-automation-dialog.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/delete-automation-dialog.tsx
new file mode 100644
index 000000000..3636c155a
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/delete-automation-dialog.tsx
@@ -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 (
+
+
+
+ Delete this automation?
+
+ {automationName} and all of its
+ triggers and run history will be removed. This cannot be undone.
+
+
+
+ Cancel
+
+ {submitting ? (
+
+
+ Deleting…
+
+ ) : (
+ "Delete"
+ )}
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/hooks/use-automation-permissions.ts b/surfsense_web/app/dashboard/[workspace_id]/automations/hooks/use-automation-permissions.ts
new file mode 100644
index 000000000..293688710
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/hooks/use-automation-permissions.ts
@@ -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]
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/new/automation-new-content.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/new/automation-new-content.tsx
new file mode 100644
index 000000000..fdbfd8697
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/new/automation-new-content.tsx
@@ -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
;
+ }
+
+ if (!perms.canCreate) {
+ return (
+
+
+
Access denied
+
+ You don't have permission to create automations in this search space.
+
+
+ );
+ }
+
+ return (
+ (
+
+ )}
+ />
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/new/components/automation-new-header.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/new/components/automation-new-header.tsx
new file mode 100644
index 000000000..57aeffd8a
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/new/components/automation-new-header.tsx
@@ -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 (
+
+
+
+
+
+ Back to automations
+
+
+ {modeSwitcher ?
{modeSwitcher}
: null}
+
+
+
+
+
New automation
+
+ Configure the task, schedule, and execution settings for this automation.
+
+
+ {modeSwitcher ? (
+
{modeSwitcher}
+ ) : null}
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/new/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/new/page.tsx
new file mode 100644
index 000000000..a9c23187e
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/new/page.tsx
@@ -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 (
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/page.tsx
new file mode 100644
index 000000000..4e4d5c055
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/automations/page.tsx
@@ -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 (
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/buy-more/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/buy-more/page.tsx
new file mode 100644
index 000000000..8ea4c1d7d
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/buy-more/page.tsx
@@ -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 (
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/buy-pages/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/buy-pages/page.tsx
new file mode 100644
index 000000000..04b0624aa
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/buy-pages/page.tsx
@@ -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;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/buy-tokens/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/buy-tokens/page.tsx
new file mode 100644
index 000000000..d2b6b8750
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/buy-tokens/page.tsx
@@ -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;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx b/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx
new file mode 100644
index 000000000..0cd650a9a
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/client-layout.tsx
@@ -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 (
+
+
+
+
+ {t("config_error")}
+
+ {t("failed_load_llm_config")}
+
+
+
+ {error instanceof Error ? error.message : String(error)}
+
+
+
+
+ );
+ }
+
+ if (isOnboardingPage) {
+ return <>{children}>;
+ }
+
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/connectors/callback/route.ts b/surfsense_web/app/dashboard/[workspace_id]/connectors/callback/route.ts
new file mode 100644
index 000000000..09a2c2f2c
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/connectors/callback/route.ts
@@ -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;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/earn-credits/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/earn-credits/page.tsx
new file mode 100644
index 000000000..3ff4c3cf8
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/earn-credits/page.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import { EarnCreditsContent } from "@/components/settings/earn-credits-content";
+
+export default function EarnCreditsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/layout.tsx b/surfsense_web/app/dashboard/[workspace_id]/layout.tsx
new file mode 100644
index 000000000..74c12fd98
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/layout.tsx
@@ -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 {children} ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/logs/(manage)/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/logs/(manage)/page.tsx
new file mode 100644
index 000000000..0dba64a3e
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/logs/(manage)/page.tsx
@@ -0,0 +1,1257 @@
+"use client";
+
+import {
+ type ColumnDef,
+ type ColumnFiltersState,
+ flexRender,
+ getCoreRowModel,
+ getFacetedUniqueValues,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ type PaginationState,
+ type Row,
+ type SortingState,
+ useReactTable,
+ type VisibilityState,
+} from "@tanstack/react-table";
+import { useAtomValue } from "jotai";
+import {
+ AlertCircle,
+ AlertTriangle,
+ Bug,
+ CheckCircle2,
+ ChevronDown,
+ ChevronFirst,
+ ChevronLast,
+ ChevronLeft,
+ ChevronRight,
+ ChevronUp,
+ CircleAlert,
+ Clock,
+ Columns3,
+ Filter,
+ Info,
+ ListFilter,
+ MoreHorizontal,
+ RefreshCw,
+ Terminal,
+ Trash,
+ Workflow,
+ X,
+ Zap,
+} from "lucide-react";
+import { AnimatePresence, motion, type Variants } from "motion/react";
+import { useParams } from "next/navigation";
+import { useTranslations } from "next-intl";
+import React, { useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
+import { toast } from "sonner";
+import {
+ createLogMutationAtom,
+ deleteLogMutationAtom,
+ updateLogMutationAtom,
+} from "@/atoms/logs/log-mutation.atoms";
+import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Pagination, PaginationContent, PaginationItem } from "@/components/ui/pagination";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import type { CreateLogRequest, Log, UpdateLogRequest } from "@/contracts/types/log.types";
+import { useDebouncedValue } from "@/hooks/use-debounced-value";
+import { type LogLevel, type LogStatus, useLogs, useLogsSummary } from "@/hooks/use-logs";
+import { cn } from "@/lib/utils";
+
+// Define animation variants for reuse
+const fadeInScale: Variants = {
+ hidden: { opacity: 0, scale: 0.95 },
+ visible: {
+ opacity: 1,
+ scale: 1,
+ transition: { type: "spring", stiffness: 300, damping: 30 },
+ },
+ exit: {
+ opacity: 0,
+ scale: 0.95,
+ transition: { duration: 0.15 },
+ },
+};
+
+// Log level icons and colors
+const logLevelConfig = {
+ DEBUG: { icon: Bug, color: "text-muted-foreground", bgColor: "bg-muted/50" },
+ INFO: { icon: Info, color: "text-blue-600", bgColor: "bg-blue-50" },
+ WARNING: { icon: AlertTriangle, color: "text-yellow-600", bgColor: "bg-yellow-50" },
+ ERROR: { icon: AlertCircle, color: "text-red-600", bgColor: "bg-red-50" },
+ CRITICAL: { icon: Zap, color: "text-purple-600", bgColor: "bg-purple-50" },
+} as const;
+
+// Log status icons and colors
+const logStatusConfig = {
+ IN_PROGRESS: { icon: Clock, color: "text-blue-600", bgColor: "bg-blue-50" },
+ SUCCESS: { icon: CheckCircle2, color: "text-green-600", bgColor: "bg-green-50" },
+ FAILED: { icon: X, color: "text-red-600", bgColor: "bg-red-50" },
+} as const;
+
+function MessageDetails({
+ message,
+ taskName,
+ createdAt,
+ children,
+}: {
+ message: string;
+ taskName?: string;
+ metadata?: any;
+ createdAt?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+
+
+
Log details
+ {createdAt && (
+
+ {new Date(createdAt).toLocaleString()}
+
+ )}
+
+
+
+
+
+ {taskName && (
+
+ {taskName}
+
+ )}
+
+
+ {message}
+
+
+
+
+
+
+ );
+}
+
+const createColumns = (t: (key: string) => string): ColumnDef[] => [
+ {
+ id: "select",
+ header: ({ table }) => (
+ table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row }) => (
+ row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+ ),
+ size: 28,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ header: t("level"),
+ accessorKey: "level",
+ cell: ({ row }) => {
+ const level = row.getValue("level") as LogLevel;
+ const config = logLevelConfig[level];
+ const Icon = config.icon;
+ return (
+
+
+
+
+ {level}
+
+ );
+ },
+ size: 120,
+ },
+ {
+ header: t("status"),
+ accessorKey: "status",
+ cell: ({ row }) => {
+ const status = row.getValue("status") as LogStatus;
+ const config = logStatusConfig[status];
+ const Icon = config.icon;
+ return (
+
+
+
+
+
+ {status.replace("_", " ")}
+
+
+ );
+ },
+ size: 140,
+ },
+ {
+ header: t("source"),
+ accessorKey: "source",
+ cell: ({ row }) => {
+ const source = row.getValue("source") as string;
+ return (
+
+
+ {source || t("system")}
+
+ );
+ },
+ size: 150,
+ },
+ {
+ header: t("message"),
+ accessorKey: "message",
+ cell: ({ row }) => {
+ const message = row.getValue("message") as string;
+ const taskName = row.original.log_metadata?.task_name;
+ const createdAt = row.getValue("created_at") as string;
+
+ return (
+
+
+ {taskName && (
+
+ {taskName}
+
+ )}
+
+ {message.length > 100 ? `${message.substring(0, 100)}...` : message}
+
+
+
+ );
+ },
+ size: 400,
+ },
+ {
+ header: t("created_at"),
+ accessorKey: "created_at",
+ cell: ({ row }) => {
+ const date = new Date(row.getValue("created_at"));
+ return (
+
+
{date.toLocaleDateString()}
+
{date.toLocaleTimeString()}
+
+ );
+ },
+ size: 120,
+ },
+ {
+ id: "actions",
+ header: () => {t("actions")} ,
+ cell: ({ row }) => ,
+ size: 60,
+ enableHiding: false,
+ },
+];
+
+// Default columns for backward compatibility
+const columns: ColumnDef[] = createColumns((key) => key);
+
+// Create a context to share functions
+const LogsContext = React.createContext<{
+ deleteLog: (id: number) => Promise;
+ refreshLogs: () => Promise;
+} | null>(null);
+
+export default function LogsManagePage() {
+ const t = useTranslations("logs");
+ const id = useId();
+ const params = useParams();
+ const workspaceId = Number(params.workspace_id);
+
+ const { mutateAsync: deleteLogMutation } = useAtomValue(deleteLogMutationAtom);
+ const { mutateAsync: updateLogMutation } = useAtomValue(updateLogMutationAtom);
+ const { mutateAsync: createLogMutation } = useAtomValue(createLogMutationAtom);
+
+ const createLog = useCallback(
+ async (data: CreateLogRequest) => {
+ try {
+ await createLogMutation(data);
+ return true;
+ } catch (error) {
+ console.error("Failed to create log:", error);
+ return false;
+ }
+ },
+ [createLogMutation]
+ );
+
+ const updateLog = useCallback(
+ async (logId: number, data: UpdateLogRequest) => {
+ try {
+ await updateLogMutation({ logId, data });
+ return true;
+ } catch (error) {
+ console.error("Failed to update log:", error);
+ return false;
+ }
+ },
+ [updateLogMutation]
+ );
+
+ const deleteLog = useCallback(
+ async (id: number) => {
+ try {
+ await deleteLogMutation({ id });
+ return true;
+ } catch (error) {
+ console.error("Failed to delete log:", error);
+ return false;
+ }
+ },
+ [deleteLogMutation]
+ );
+
+ const { logs, loading: logsLoading, error: logsError, refreshLogs } = useLogs(workspaceId);
+ const {
+ summary,
+ loading: summaryLoading,
+ error: summaryError,
+ refreshSummary,
+ } = useLogsSummary(workspaceId, 24);
+
+ const [columnFilters, setColumnFilters] = useState([]);
+ const [columnVisibility, setColumnVisibility] = useState({});
+ const [pagination, setPagination] = useState({
+ pageIndex: 0,
+ pageSize: 20,
+ });
+ const [sorting, setSorting] = useState([
+ {
+ id: "created_at",
+ desc: true,
+ },
+ ]);
+
+ const inputRef = useRef(null);
+
+ // Create translated columns
+ const translatedColumns = useMemo(() => createColumns(t), [t]);
+
+ const table = useReactTable({
+ data: logs,
+ columns: translatedColumns,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ onSortingChange: setSorting,
+ enableSortingRemoval: false,
+ getPaginationRowModel: getPaginationRowModel(),
+ onPaginationChange: setPagination,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ getFilteredRowModel: getFilteredRowModel(),
+ getFacetedUniqueValues: getFacetedUniqueValues(),
+ state: {
+ sorting,
+ pagination,
+ columnFilters,
+ columnVisibility,
+ },
+ });
+
+ // Get unique values for filters
+ const uniqueLevels = useMemo(() => {
+ const levelColumn = table.getColumn("level");
+ if (!levelColumn) return [];
+ return Array.from(levelColumn.getFacetedUniqueValues().keys()).sort();
+ }, [table.getColumn]);
+
+ const uniqueStatuses = useMemo(() => {
+ const statusColumn = table.getColumn("status");
+ if (!statusColumn) return [];
+ return Array.from(statusColumn.getFacetedUniqueValues().keys()).sort();
+ }, [table.getColumn]);
+
+ const handleDeleteRows = async () => {
+ const selectedRows = table.getSelectedRowModel().rows;
+
+ if (selectedRows.length === 0) {
+ toast.error("No rows selected");
+ return;
+ }
+
+ const deletePromises = selectedRows.map((row) => deleteLog(row.original.id)); // Already passes { id } via wrapper
+
+ try {
+ const results = await Promise.all(deletePromises);
+ const allSuccessful = results.every((result) => result === true);
+
+ if (allSuccessful) {
+ toast.success(`Successfully deleted ${selectedRows.length} log(s)`);
+ } else {
+ toast.error("Some logs could not be deleted");
+ }
+
+ await refreshLogs();
+ table.resetRowSelection();
+ } catch (error: any) {
+ console.error("Error deleting logs:", error);
+ toast.error("Error deleting logs");
+ }
+ };
+
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [isSummaryRefreshing, setIsSummaryRefreshing] = useState(false);
+
+ const handleRefresh = async () => {
+ if (isRefreshing) return;
+ setIsRefreshing(true);
+ try {
+ await Promise.all([refreshLogs(), refreshSummary()]);
+ toast.success("Logs refreshed");
+ } finally {
+ setIsRefreshing(false);
+ }
+ };
+
+ return (
+ Promise.resolve(false)),
+ refreshLogs: () => refreshLogs().then(() => void 0),
+ }}
+ >
+
+ {/* Summary Dashboard */}
+ {
+ if (isSummaryRefreshing) return;
+ setIsSummaryRefreshing(true);
+ try {
+ await refreshSummary();
+ } finally {
+ setIsSummaryRefreshing(false);
+ }
+ }}
+ isRefreshing={isSummaryRefreshing}
+ />
+
+ {/* Logs Table Header */}
+
+
+
{t("title")}
+
{t("subtitle")}
+
+
+
+ {t("refresh")}
+
+
+
+ {/* Filters */}
+
+
+ {/* Logs Table */}
+
+
+
+ );
+}
+
+// Summary Dashboard Component
+function LogsSummaryDashboard({
+ summary,
+ loading,
+ error,
+ onRefresh,
+ isRefreshing = false,
+}: {
+ summary: any;
+ loading: boolean;
+ error: string | null;
+ onRefresh: () => void | Promise;
+ isRefreshing?: boolean;
+}) {
+ const t = useTranslations("logs");
+ if (loading) {
+ return (
+
+ {[...Array(4)].map((_, i) => (
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+ }
+
+ if (error || !summary) {
+ return (
+
+
+
+
+
{t("failed_load_summary")}
+
+
+ {t("retry")}
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Total Logs */}
+
+
+
+ {t("total_logs")}
+
+
+
+ {summary.total_logs}
+
+ {t("last_hours", { hours: summary.time_window_hours })}
+
+
+
+
+
+ {/* Active Tasks */}
+
+
+
+ {t("active_tasks")}
+
+
+
+
+ {summary.active_tasks?.length || 0}
+
+ {t("currently_running")}
+
+
+
+
+ {/* Success Rate */}
+
+
+
+ {t("success_rate")}
+
+
+
+
+ {summary.total_logs > 0
+ ? Math.round(((summary.by_status?.SUCCESS || 0) / summary.total_logs) * 100)
+ : 0}
+ %
+
+
+ {summary.by_status?.SUCCESS || 0} {t("successful")}
+
+
+
+
+
+ {/* Recent Failures */}
+
+
+
+ {t("recent_failures")}
+
+
+
+
+ {summary.recent_failures?.length || 0}
+
+ {t("need_attention")}
+
+
+
+
+ );
+}
+
+// Filters Component
+function LogsFilters({
+ table,
+ uniqueLevels,
+ uniqueStatuses,
+ inputRef,
+ onBulkDelete,
+ id,
+}: {
+ table: any;
+ uniqueLevels: string[];
+ uniqueStatuses: string[];
+ inputRef: React.RefObject;
+ onBulkDelete: () => Promise;
+ id: string;
+}) {
+ const t = useTranslations("logs");
+ const [filterInput, setFilterInput] = useState(
+ (table.getColumn("message")?.getFilterValue() ?? "") as string
+ );
+ const debouncedFilter = useDebouncedValue(filterInput, 300);
+
+ useEffect(() => {
+ table.getColumn("message")?.setFilterValue(debouncedFilter || undefined);
+ }, [debouncedFilter, table]);
+
+ return (
+
+
+ {/* Search Input */}
+
+ setFilterInput(e.target.value)}
+ placeholder={t("filter_by_message")}
+ type="text"
+ />
+
+
+
+ {Boolean(filterInput) && (
+ {
+ setFilterInput("");
+ table.getColumn("message")?.setFilterValue("");
+ inputRef.current?.focus();
+ }}
+ >
+
+
+ )}
+
+
+ {/* Level Filter */}
+
+
+ {/* Status Filter */}
+
+
+ {/* Column Visibility */}
+
+
+
+
+ {t("view")}
+
+
+
+ {t("toggle_columns")}
+ {table
+ .getAllColumns()
+ .filter((column: any) => column.getCanHide())
+ .map((column: any) => (
+ column.toggleVisibility(!!value)}
+ onSelect={(event) => event.preventDefault()}
+ >
+ {column.id}
+
+ ))}
+
+
+
+
+
+ {table.getSelectedRowModel().rows.length > 0 && (
+
+
+
+
+ {t("delete_selected")}
+
+ {table.getSelectedRowModel().rows.length}
+
+
+
+
+
+
+
+
+
+ {t("confirm_title")}
+
+ {t("confirm_delete_desc", { count: table.getSelectedRowModel().rows.length })}
+
+
+
+
+ {t("cancel")}
+ {t("delete")}
+
+
+
+ )}
+
+
+ );
+}
+
+// Filter Dropdown Component
+function FilterDropdown({
+ title,
+ column,
+ options,
+ id,
+ t,
+}: {
+ title: string;
+ column: any;
+ options: string[];
+ id: string;
+ t: (key: string) => string;
+}) {
+ const selectedValues = useMemo(() => {
+ const filterValue = column?.getFilterValue() as string[];
+ return filterValue ?? [];
+ }, [column?.getFilterValue]);
+
+ const handleValueChange = (checked: boolean, value: string) => {
+ const filterValue = column?.getFilterValue() as string[];
+ const newFilterValue = filterValue ? [...filterValue] : [];
+
+ if (checked) {
+ newFilterValue.push(value);
+ } else {
+ const index = newFilterValue.indexOf(value);
+ if (index > -1) {
+ newFilterValue.splice(index, 1);
+ }
+ }
+
+ column?.setFilterValue(newFilterValue.length ? newFilterValue : undefined);
+ };
+
+ return (
+
+
+
+
+ {title}
+ {selectedValues.length > 0 && (
+
+ {selectedValues.length}
+
+ )}
+
+
+
+
+
+ {t("filter_by")} {title}
+
+
+ {options.map((value, i) => (
+
+ handleValueChange(checked, value)}
+ />
+
+ {value}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+// Logs Table Component
+function LogsTable({
+ table,
+ logs,
+ loading,
+ error,
+ onRefresh,
+ id,
+ t,
+}: {
+ table: any;
+ logs: Log[];
+ loading: boolean;
+ error: string | null;
+ onRefresh: () => void;
+ id: string;
+ t: (key: string, params?: any) => string;
+}) {
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+
Error loading logs
+
+ Retry
+
+
+
+
+ );
+ }
+
+ if (logs.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+ {table.getHeaderGroups().map((headerGroup: any) => (
+
+ {headerGroup.headers.map((header: any) => (
+
+ {header.isPlaceholder ? null : header.column.getCanSort() ? (
+
+ {flexRender(header.column.columnDef.header, header.getContext())}
+ {{
+ asc: ,
+ desc: ,
+ }[header.column.getIsSorted() as string] ?? null}
+
+ ) : (
+ flexRender(header.column.columnDef.header, header.getContext())
+ )}
+
+ ))}
+
+ ))}
+
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row: any, index: number) => (
+
+ {row.getVisibleCells().map((cell: any) => {
+ const isCreatedAt = cell.column.id === "created_at";
+ const isMessage = cell.column.id === "message";
+ return (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ );
+ })}
+
+ ))
+ ) : (
+
+
+ {t("no_logs")}
+
+
+ )}
+
+
+
+
+
+ {/* Pagination */}
+
+ >
+ );
+}
+
+// Pagination Component
+function LogsPagination({ table, id, t }: { table: any; id: string; t: (key: string) => string }) {
+ return (
+
+
+
+ {t("rows_per_page")}
+
+ table.setPageSize(Number(value))}
+ >
+
+
+
+
+ {[10, 20, 50, 100].map((pageSize) => (
+
+ {pageSize}
+
+ ))}
+
+
+
+
+
+
+
+ {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}-
+ {Math.min(
+ table.getState().pagination.pageIndex * table.getState().pagination.pageSize +
+ table.getState().pagination.pageSize,
+ table.getRowCount()
+ )}
+ {" "}
+ of {table.getRowCount()}
+
+
+
+
+
+
+
+ table.firstPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+
+
+
+
+ table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+
+
+
+
+ table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+
+
+
+
+ table.lastPage()}
+ disabled={!table.getCanNextPage()}
+ >
+
+
+
+
+
+
+
+ );
+}
+
+// Row Actions Component
+function LogRowActions({ row, t }: { row: Row; t: (key: string) => string }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const { deleteLog, refreshLogs } = useContext(LogsContext)!;
+ const log = row.original;
+
+ const handleDelete = async () => {
+ setIsDeleting(true);
+ try {
+ await deleteLog(log.id);
+ // toast.success(t("log_deleted_success"));
+ await refreshLogs();
+ } catch (error) {
+ console.error("Error deleting log:", error);
+ toast.error(t("log_deleted_error"));
+ } finally {
+ setIsDeleting(false);
+ setIsOpen(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ e.preventDefault()}>
+ {t("view_metadata")}
+
+ }
+ />
+
+
+
+ {
+ e.preventDefault();
+ setIsOpen(true);
+ }}
+ >
+ {t("delete")}
+
+
+
+
+ {t("confirm_delete_log_title")}
+ {t("confirm_delete_log_desc")}
+
+
+ {t("cancel")}
+
+ {isDeleting ? t("deleting") : t("delete")}
+
+
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/logs/loading.tsx b/surfsense_web/app/dashboard/[workspace_id]/logs/loading.tsx
new file mode 100644
index 000000000..e734d4e53
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/logs/loading.tsx
@@ -0,0 +1,100 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+export default function Loading() {
+ return (
+
+ {/* Summary Dashboard Skeleton */}
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+
+ {/* Header Section Skeleton */}
+
+
+ {/* Filters Skeleton */}
+
+
+ {/* Table Skeleton */}
+
+ {/* Table Header */}
+
+
+
+
+
+
+
+
+
+
+ {/* Table Rows */}
+ {[...Array(6)].map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {/* Pagination Skeleton */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/more-pages/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/more-pages/page.tsx
new file mode 100644
index 000000000..d1a42514b
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/more-pages/page.tsx
@@ -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;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx
new file mode 100644
index 000000000..b8a684524
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx
@@ -0,0 +1,2572 @@
+"use client";
+
+import {
+ type AppendMessage,
+ AssistantRuntimeProvider,
+ type ThreadMessageLike,
+ useExternalStoreRuntime,
+} from "@assistant-ui/react";
+import { useQueryClient } from "@tanstack/react-query";
+import { useAtomValue, useSetAtom, useStore } from "jotai";
+import dynamic from "next/dynamic";
+import { useParams } from "next/navigation";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { toast } from "sonner";
+import { z } from "zod";
+import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
+import { disabledToolsAtom } from "@/atoms/agent-tools/agent-tools.atoms";
+import {
+ clearTargetCommentIdAtom,
+ currentThreadAtom,
+ setCurrentThreadMetadataAtom,
+ setTargetCommentIdAtom,
+} from "@/atoms/chat/current-thread.atom";
+import {
+ deriveMentionedPayload,
+ type MentionedDocumentInfo,
+ mentionedDocumentsAtom,
+ messageDocumentsMapAtom,
+ submittedMentionsAtom,
+} from "@/atoms/chat/mentioned-documents.atom";
+import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
+import {
+ clearPlanOwnerRegistry,
+ // extractWriteTodosFromContent,
+} from "@/atoms/chat/plan-state.atom";
+import { setPremiumAlertForThreadAtom } from "@/atoms/chat/premium-alert.atom";
+import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
+import { type AgentCreatedDocument, agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
+import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
+import { membersAtom } from "@/atoms/members/members-query.atoms";
+import { removeChatTabAtom, syncChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
+import { currentUserAtom } from "@/atoms/user/user-query.atoms";
+import {
+ EditMessageDialog,
+ type EditMessageDialogChoice,
+} from "@/components/assistant-ui/edit-message-dialog";
+import { StepSeparatorDataUI } from "@/components/assistant-ui/step-separator";
+import { Thread } from "@/components/assistant-ui/thread";
+import {
+ createTokenUsageStore,
+ type TokenUsageData,
+ TokenUsageProvider,
+} from "@/components/assistant-ui/token-usage-context";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useSyncChatArtifacts } from "@/features/chat-artifacts";
+import {
+ type HitlDecision,
+ PendingInterruptProvider,
+ type PendingInterruptState,
+} from "@/features/chat-messages/hitl";
+import { TimelineDataUI } from "@/features/chat-messages/timeline";
+import {
+ applyActionLogSse,
+ applyActionLogUpdatedSse,
+ markActionRevertedInCache,
+ useAgentActionsQuery,
+} from "@/hooks/use-agent-actions-query";
+import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
+import { useMessagesSync } from "@/hooks/use-messages-sync";
+import { useThreadDetail, useThreadMessages } from "@/hooks/use-thread-queries";
+import { getAgentFilesystemSelection } from "@/lib/agent-filesystem";
+import { documentsApiService } from "@/lib/apis/documents-api.service";
+import { authenticatedFetch } from "@/lib/auth-fetch";
+import { type ChatFlow, classifyChatError } from "@/lib/chat/chat-error-classifier";
+import { tagPreAcceptSendFailure, toHttpResponseError } from "@/lib/chat/chat-request-errors";
+import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
+import {
+ convertToThreadMessage,
+ reconcileInterruptedAssistantMessages,
+} from "@/lib/chat/message-utils";
+import { createStreamFlushHelpers } from "@/lib/chat/stream-flush";
+import { consumeSseEvents, processSharedStreamEvent } from "@/lib/chat/stream-pipeline";
+import {
+ applyTurnIdToAssistantMessageList,
+ mergeChatTurnIdIntoMessage,
+ readStreamedChatTurnId,
+ readStreamedMessageId,
+} from "@/lib/chat/stream-side-effects";
+import {
+ addToolCall,
+ buildContentForUI,
+ type ContentPartsState,
+ type FrameBatchedUpdater,
+ type ThinkingStepData,
+ type ToolUIGate,
+ updateToolCall,
+} from "@/lib/chat/streaming-state";
+import {
+ appendMessage,
+ createThread,
+ getRegenerateUrl,
+ type ThreadListItem,
+ type ThreadListResponse,
+ type ThreadRecord,
+} from "@/lib/chat/thread-persistence";
+import {
+ extractUserTurnForNewChatApi,
+ type NewChatUserImagePayload,
+} from "@/lib/chat/user-turn-api-parts";
+import { buildBackendUrl } from "@/lib/env-config";
+import { NotFoundError } from "@/lib/error";
+import {
+ trackChatBlocked,
+ trackChatCreated,
+ trackChatErrorDetailed,
+ trackChatMessageSent,
+ trackChatResponseReceived,
+} from "@/lib/posthog/events";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
+
+const MobileEditorPanel = dynamic(
+ () =>
+ import("@/components/editor-panel/editor-panel").then((m) => ({
+ default: m.MobileEditorPanel,
+ })),
+ { ssr: false }
+);
+const MobileHitlEditPanel = dynamic(
+ () =>
+ import("@/features/chat-messages/hitl").then((m) => ({
+ default: m.MobileHitlEditPanel,
+ })),
+ { ssr: false }
+);
+const MobileReportPanel = dynamic(
+ () =>
+ import("@/components/report-panel/report-panel").then((m) => ({
+ default: m.MobileReportPanel,
+ })),
+ { ssr: false }
+);
+const MobileArtifactsPanel = dynamic(
+ () =>
+ import("@/features/chat-artifacts/ui/artifacts-panel").then((m) => ({
+ default: m.MobileArtifactsPanel,
+ })),
+ { ssr: false }
+);
+
+/**
+ * Generate a synthetic ``toolCallId`` for an action_request that has no
+ * matching streamed tool-call card (HITL-blocked subagent calls don't surface
+ * as tool-call events). Suffixes a counter when the base id is already taken
+ * — sequential interrupts for the same tool name otherwise collide on
+ * ``interrupt-${name}-${i}`` and crash assistant-ui with a duplicate-key error.
+ */
+function freshSynthToolCallId(
+ toolCallIndices: Map,
+ toolName: string,
+ index: number
+): string {
+ const base = `interrupt-${toolName}-${index}`;
+ if (!toolCallIndices.has(base)) return base;
+ let n = 1;
+ while (toolCallIndices.has(`${base}-${n}`)) n++;
+ return `${base}-${n}`;
+}
+
+/**
+ * Pair each ``action_request`` to a unique pending tool-call card, preserving
+ * order so ``decisions[i]`` lines up with ``action_requests[i]`` on the wire.
+ *
+ * Same-name bundles (e.g. three ``create_jira_issue``) used to collapse onto
+ * one card because the matcher keyed by name; this consumes each card via the
+ * ``claimed`` set and walks forward in DOM order.
+ */
+function pairBundleToolCallIds(
+ toolCallIndices: Map,
+ contentParts: Array<{
+ type: string;
+ toolName?: string;
+ result?: unknown;
+ }>,
+ actionRequests: ReadonlyArray<{ name: string }>
+): Array {
+ const claimed = new Set();
+ const paired: Array = [];
+ for (const action of actionRequests) {
+ let matched: string | null = null;
+ for (const [tcId, idx] of toolCallIndices) {
+ if (claimed.has(tcId)) continue;
+ const part = contentParts[idx];
+ if (!part || part.type !== "tool-call" || part.toolName !== action.name) continue;
+ const result = part.result as Record | undefined | null;
+ if (result == null || (result.__interrupt__ === true && !result.__decided__)) {
+ matched = tcId;
+ claimed.add(tcId);
+ break;
+ }
+ }
+ paired.push(matched);
+ }
+ return paired;
+}
+
+/**
+ * Zod schema for mentioned document info (for type-safe parsing).
+ *
+ * ``kind`` defaults to ``"doc"`` so messages persisted before folder
+ * mentions existed deserialise unchanged.
+ */
+const MentionedDocumentInfoSchema = z.object({
+ id: z.number(),
+ title: z.string(),
+ document_type: z.string().optional(),
+ kind: z
+ .union([z.literal("doc"), z.literal("folder"), z.literal("connector"), z.literal("thread")])
+ .optional()
+ .default("doc"),
+ connector_type: z.string().optional(),
+ account_name: z.string().optional(),
+});
+
+const MentionedDocumentsPartSchema = z.object({
+ type: z.literal("mentioned-documents"),
+ documents: z.array(MentionedDocumentInfoSchema),
+});
+
+/**
+ * Extract mentioned documents from message content (type-safe with Zod)
+ */
+function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
+ if (!Array.isArray(content)) return [];
+
+ for (const part of content) {
+ const result = MentionedDocumentsPartSchema.safeParse(part);
+ if (result.success) {
+ return result.data.documents.map((doc) => {
+ if (doc.kind === "connector") {
+ return {
+ id: doc.id,
+ title: doc.title,
+ kind: "connector",
+ connector_type: doc.connector_type ?? doc.document_type ?? "UNKNOWN",
+ account_name: doc.account_name ?? doc.title,
+ };
+ }
+ if (doc.kind === "folder") {
+ return {
+ id: doc.id,
+ title: doc.title,
+ kind: "folder",
+ };
+ }
+ if (doc.kind === "thread") {
+ return {
+ id: doc.id,
+ title: doc.title,
+ kind: "thread",
+ };
+ }
+ return {
+ id: doc.id,
+ title: doc.title,
+ document_type: doc.document_type ?? "UNKNOWN",
+ kind: "doc",
+ };
+ });
+ }
+ }
+
+ return [];
+}
+
+/**
+ * Every tool call renders a card. The legacy
+ * ``BASE_TOOLS_WITH_UI`` allowlist used to drop unknown tool calls on the
+ * floor; we now route everything through ``ToolFallback``. Persisted
+ * payload size stays bounded because the backend's
+ * ``format_thinking_step`` summarisation and the
+ * ``result_length``-only default for unknown tools (see
+ * ``stream_new_chat.py``) keep the JSON from ballooning.
+ */
+const TOOLS_WITH_UI_ALL: ToolUIGate = "all";
+const TURN_CANCELLING_INITIAL_DELAY_MS = 200;
+const TURN_CANCELLING_BACKOFF_FACTOR = 2;
+const TURN_CANCELLING_MAX_DELAY_MS = 1500;
+const RECENT_CANCEL_WINDOW_MS = 5_000;
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function computeFallbackTurnCancellingRetryDelay(attempt: number): number {
+ const safeAttempt = Math.max(1, attempt);
+ const raw =
+ TURN_CANCELLING_INITIAL_DELAY_MS * TURN_CANCELLING_BACKOFF_FACTOR ** (safeAttempt - 1);
+ return Math.min(raw, TURN_CANCELLING_MAX_DELAY_MS);
+}
+
+function parseUrlChatId(id: string | string[] | undefined): number {
+ let parsed = 0;
+ if (Array.isArray(id) && id.length > 0) {
+ parsed = Number.parseInt(id[0], 10);
+ } else if (typeof id === "string") {
+ parsed = Number.parseInt(id, 10);
+ }
+ return Number.isNaN(parsed) ? 0 : parsed;
+}
+
+function ThreadMessagesSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function NewChatPage() {
+ const params = useParams();
+ const queryClient = useQueryClient();
+ const urlChatId = useMemo(() => parseUrlChatId(params.chat_id), [params.chat_id]);
+ const [threadId, setThreadId] = useState(() => (urlChatId > 0 ? urlChatId : null));
+ const activeThreadId = urlChatId > 0 ? urlChatId : threadId;
+ const handledLoadErrorThreadRef = useRef(null);
+ const [currentThread, setCurrentThread] = useState(null);
+ const [messages, setMessages] = useState([]);
+ const [isRunning, setIsRunning] = useState(false);
+ const [tokenUsageStore] = useState(() => createTokenUsageStore());
+ const abortControllerRef = useRef(null);
+ const recentCancelRequestedAtRef = useRef(0);
+ // One entry per paused subagent, in receipt order (which matches the
+ // backend's ``state.interrupts`` traversal — and therefore the order
+ // ``slice_decisions_by_tool_call`` consumes on resume). Cleared on submit
+ // or on a fresh user turn.
+ const [pendingInterrupts, setPendingInterrupts] = useState([]);
+ // Per-card staged decisions held until every pending card has submitted,
+ // at which point we batch them into one ``hitl-decision`` event in the
+ // same order as ``pendingInterrupts``. Using a ref because partial
+ // progress should not re-render the page.
+ const stagedDecisionsByInterruptIdRef = useRef>(new Map());
+ const toolsWithUI = TOOLS_WITH_UI_ALL;
+ const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
+
+ const persistAssistantErrorMessage = useCallback(
+ async ({
+ threadId,
+ assistantMsgId,
+ text,
+ }: {
+ threadId: number | null;
+ assistantMsgId: string;
+ text: string;
+ }) => {
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === assistantMsgId
+ ? {
+ ...m,
+ content: [{ type: "text", text }],
+ }
+ : m
+ )
+ );
+
+ if (!threadId) return;
+
+ // Persist only temporary assistant placeholders to avoid duplicate rows
+ // when the message already has a database-backed ID.
+ if (!assistantMsgId.startsWith("msg-assistant-")) return;
+
+ try {
+ const savedMessage = await appendMessage(threadId, {
+ role: "assistant",
+ content: [{ type: "text", text }],
+ });
+ const newMsgId = `msg-${savedMessage.id}`;
+ tokenUsageStore.rename(assistantMsgId, newMsgId);
+ setMessages((prev) =>
+ prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
+ );
+ } catch (persistErr) {
+ console.error("Failed to persist assistant error message:", persistErr);
+ }
+ },
+ [tokenUsageStore]
+ );
+
+ // NOTE: ``persistUserTurn`` / ``persistAssistantTurn`` callbacks
+ // were removed in the SSE-based message ID handshake refactor.
+ // ``stream_new_chat`` and ``stream_resume_chat`` now persist both
+ // the user and assistant rows server-side via
+ // ``persist_user_turn`` / ``persist_assistant_shell`` and emit
+ // ``data-user-message-id`` / ``data-assistant-message-id`` SSE
+ // events; the consumers below rename the optimistic ids in real
+ // time. ``persistAssistantErrorMessage`` (above) is intentionally
+ // kept — it is the pre-stream-error fallback fired when the
+ // server NEVER accepted the request, and the BE has nothing to
+ // persist in that case.
+
+ // Get disabled tools from the tool toggle UI
+ const disabledTools = useAtomValue(disabledToolsAtom);
+
+ const jotaiStore = useStore();
+ const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
+ const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
+ const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
+ const currentThreadState = useAtomValue(currentThreadAtom);
+ const setCurrentThreadMetadata = useSetAtom(setCurrentThreadMetadataAtom);
+ const setPremiumAlertForThread = useSetAtom(setPremiumAlertForThreadAtom);
+ const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
+ const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
+ const closeReportPanel = useSetAtom(closeReportPanelAtom);
+ const closeEditorPanel = useSetAtom(closeEditorPanelAtom);
+ const syncChatTab = useSetAtom(syncChatTabAtom);
+ const updateChatTabTitle = useSetAtom(updateChatTabTitleAtom);
+ const removeChatTab = useSetAtom(removeChatTabAtom);
+ const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom);
+ const pendingUserImageUrls = useAtomValue(pendingUserImageDataUrlsAtom);
+ const setPendingUserImageUrls = useSetAtom(pendingUserImageDataUrlsAtom);
+ // Edit dialog state. Holds the message id being edited and
+ // the (already extracted) regenerate args so we can resume the edit
+ // after the user picks "revert all" / "continue" / "cancel".
+ const [editDialogState, setEditDialogState] = useState<{
+ fromMessageId: number;
+ userQuery: string | null;
+ userMessageContent: ThreadMessageLike["content"];
+ userImages: NewChatUserImagePayload[];
+ downstreamReversibleCount: number;
+ downstreamTotalCount: number;
+ } | null>(null);
+
+ // Get current user for author info in shared chats
+ const { data: currentUser } = useAtomValue(currentUserAtom);
+ const { data: agentFlags } = useAtomValue(agentFlagsAtom);
+ const localFilesystemEnabled = agentFlags?.enable_desktop_local_filesystem === true;
+ const threadDetailQuery = useThreadDetail(activeThreadId);
+ const threadMessagesQuery = useThreadMessages(activeThreadId);
+
+ // Live collaboration: sync session state and messages via Zero
+ useChatSessionStateSync(activeThreadId);
+ const { data: membersData } = useAtomValue(membersAtom);
+
+ const handleSyncedMessagesUpdate = useCallback(
+ (
+ syncedMessages: {
+ id: number;
+ thread_id: number;
+ role: string;
+ content: unknown;
+ author_id: string | null;
+ created_at: string;
+ // Forwarded so ``convertToThreadMessage`` can rebuild the
+ // ``metadata.custom.chatTurnId`` on the
+ // ``ThreadMessageLike``. Required by the inline Revert
+ // button's per-turn fallback.
+ turn_id?: string | null;
+ }[]
+ ) => {
+ if (isRunning) {
+ return;
+ }
+
+ setMessages((prev) => {
+ if (syncedMessages.length < prev.length) {
+ return prev;
+ }
+
+ const memberById = new Map(membersData?.map((m) => [m.user_id, m]) ?? []);
+ const prevById = new Map(prev.map((m) => [m.id, m]));
+
+ return reconcileInterruptedAssistantMessages(syncedMessages).map((msg) => {
+ const member = msg.author_id ? (memberById.get(msg.author_id) ?? null) : null;
+
+ // Preserve existing author info if member lookup fails (e.g., cloned chats)
+ const existingMsg = prevById.get(`msg-${msg.id}`);
+ const existingAuthor = existingMsg?.metadata?.custom?.author as
+ | { displayName?: string | null; avatarUrl?: string | null }
+ | undefined;
+
+ return convertToThreadMessage({
+ id: msg.id,
+ thread_id: msg.thread_id,
+ role: msg.role.toLowerCase() as "user" | "assistant" | "system",
+ content: msg.content,
+ author_id: msg.author_id,
+ created_at: msg.created_at,
+ author_display_name: member?.user_display_name ?? existingAuthor?.displayName ?? null,
+ author_avatar_url: member?.user_avatar_url ?? existingAuthor?.avatarUrl ?? null,
+ // Forward the per-turn correlation id so the
+ // inline Revert button's ``(chat_turn_id,
+ // tool_name, position)`` fallback survives the
+ // post-stream Zero re-sync.
+ turn_id: msg.turn_id ?? null,
+ });
+ });
+ });
+ },
+ [isRunning, membersData]
+ );
+
+ useMessagesSync(activeThreadId, handleSyncedMessagesUpdate);
+
+ // Extract workspace_id from URL params
+ const workspaceId = useMemo(() => {
+ const id = params.workspace_id;
+ const parsed = typeof id === "string" ? Number.parseInt(id, 10) : 0;
+ return Number.isNaN(parsed) ? 0 : parsed;
+ }, [params.workspace_id]);
+
+ // Unified store for agent-action rows (the same react-query cache
+ // the agent-actions dialog, the inline Revert button, and the
+ // per-turn Revert button all read). Hydrates from
+ // ``GET /threads/{id}/actions`` and is updated incrementally by the
+ // SSE handlers + revert-batch results below — no atom side-channel.
+ const { items: agentActionItems } = useAgentActionsQuery(activeThreadId);
+
+ const handleChatFailure = useCallback(
+ async ({
+ error,
+ flow,
+ threadId,
+ assistantMsgId,
+ }: {
+ error: unknown;
+ flow: ChatFlow;
+ threadId: number | null;
+ assistantMsgId: string;
+ }) => {
+ const normalized = classifyChatError({
+ error,
+ flow,
+ context: {
+ workspaceId,
+ threadId,
+ },
+ });
+
+ const logger =
+ normalized.severity === "error"
+ ? console.error
+ : normalized.severity === "warn"
+ ? console.warn
+ : console.info;
+ logger(`[NewChatPage] ${flow} ${normalized.kind}:`, error);
+
+ const telemetryPayload = {
+ flow,
+ kind: normalized.kind,
+ error_code: normalized.errorCode,
+ severity: normalized.severity,
+ is_expected: normalized.isExpected,
+ message: normalized.userMessage,
+ };
+ if (normalized.telemetryEvent === "chat_blocked") {
+ trackChatBlocked(workspaceId, threadId, telemetryPayload);
+ } else {
+ trackChatErrorDetailed(workspaceId, threadId, telemetryPayload);
+ }
+
+ if (normalized.channel === "silent") {
+ return;
+ }
+
+ if (normalized.channel === "pinned_inline") {
+ if (threadId) {
+ setPremiumAlertForThread({
+ threadId,
+ message: normalized.userMessage,
+ userId: currentUser?.id ?? null,
+ });
+ }
+ if (normalized.assistantMessage) {
+ await persistAssistantErrorMessage({
+ threadId,
+ assistantMsgId,
+ text: normalized.assistantMessage,
+ });
+ }
+ return;
+ }
+
+ if (normalized.channel === "inline") {
+ if (normalized.assistantMessage) {
+ await persistAssistantErrorMessage({
+ threadId,
+ assistantMsgId,
+ text: normalized.assistantMessage,
+ });
+ }
+ toast.error(normalized.userMessage);
+ return;
+ }
+
+ toast.error(normalized.userMessage);
+ },
+ [currentUser?.id, persistAssistantErrorMessage, workspaceId, setPremiumAlertForThread]
+ );
+
+ const handleStreamTerminalError = useCallback(
+ async ({
+ error,
+ flow,
+ threadId,
+ assistantMsgId,
+ accepted,
+ onAbort,
+ onPreAcceptFailure,
+ onAcceptedStreamError,
+ }: {
+ error: unknown;
+ flow: ChatFlow;
+ threadId: number | null;
+ assistantMsgId: string;
+ accepted: boolean;
+ onAbort?: () => Promise;
+ onPreAcceptFailure?: () => Promise;
+ onAcceptedStreamError?: () => Promise;
+ }) => {
+ if (error instanceof Error && error.name === "AbortError") {
+ await onAbort?.();
+ return;
+ }
+
+ if (!accepted) {
+ await onPreAcceptFailure?.();
+ } else {
+ await onAcceptedStreamError?.();
+ }
+
+ await handleChatFailure({
+ error: !accepted ? tagPreAcceptSendFailure(error) : error,
+ flow,
+ threadId,
+ assistantMsgId: accepted ? assistantMsgId : "no-persist-assistant",
+ });
+ },
+ [handleChatFailure]
+ );
+
+ const fetchWithTurnCancellingRetry = useCallback(async (runFetch: () => Promise) => {
+ const maxAttempts = 4;
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
+ const response = await runFetch();
+ if (response.ok) {
+ return response;
+ }
+ const error = await toHttpResponseError(response);
+ const withMeta = error as Error & { errorCode?: string; retryAfterMs?: number };
+ const isTurnCancelling = withMeta.errorCode === "TURN_CANCELLING";
+ const isRecentThreadBusyAfterCancel =
+ withMeta.errorCode === "THREAD_BUSY" &&
+ Date.now() - recentCancelRequestedAtRef.current <= RECENT_CANCEL_WINDOW_MS;
+ if ((isTurnCancelling || isRecentThreadBusyAfterCancel) && attempt < maxAttempts) {
+ const waitMs = withMeta.retryAfterMs ?? computeFallbackTurnCancellingRetryDelay(attempt);
+ await sleep(waitMs);
+ continue;
+ }
+ throw error;
+ }
+
+ throw Object.assign(new Error("Turn cancellation retry limit exceeded"), {
+ errorCode: "TURN_CANCELLING",
+ });
+ }, []);
+
+ const hydratedMessagesRef = useRef<{
+ threadId: number | null;
+ data: typeof threadMessagesQuery.data;
+ }>({ threadId: null, data: undefined });
+
+ // Reset thread-local runtime state on route/search-space changes. Data fetching
+ // is handled by React Query below so the chat shell can render immediately.
+ useEffect(() => {
+ const nextThreadId = urlChatId > 0 ? urlChatId : null;
+ handledLoadErrorThreadRef.current = null;
+ hydratedMessagesRef.current = { threadId: null, data: undefined };
+ setThreadId(nextThreadId);
+ setMessages([]);
+ setCurrentThread(null);
+ setMentionedDocuments([]);
+ tokenUsageStore.clear();
+ setMessageDocumentsMap({});
+ clearPlanOwnerRegistry();
+ closeReportPanel();
+ closeEditorPanel();
+ // Note: agent-action data is keyed by threadId in react-query so
+ // switching threads naturally swaps caches; no explicit reset.
+ }, [
+ urlChatId,
+ setMentionedDocuments,
+ setMessageDocumentsMap,
+ tokenUsageStore,
+ closeReportPanel,
+ closeEditorPanel,
+ ]);
+
+ useEffect(() => {
+ if (!activeThreadId) {
+ setCurrentThread(null);
+ return;
+ }
+ if (threadDetailQuery.data?.id === activeThreadId) {
+ const thread = threadDetailQuery.data;
+ setCurrentThread(thread);
+ syncChatTab({
+ chatId: thread.id,
+ title: thread.title,
+ chatUrl: `/dashboard/${thread.workspace_id ?? workspaceId}/new-chat/${thread.id}`,
+ workspaceId: thread.workspace_id ?? workspaceId,
+ visibility: thread.visibility,
+ hasComments: thread.has_comments ?? false,
+ });
+ }
+ }, [activeThreadId, workspaceId, syncChatTab, threadDetailQuery.data]);
+
+ useEffect(() => {
+ const messagesResponse = threadMessagesQuery.data;
+ if (!activeThreadId || !messagesResponse) return;
+
+ if (
+ hydratedMessagesRef.current.threadId === activeThreadId &&
+ hydratedMessagesRef.current.data === messagesResponse
+ ) {
+ return;
+ }
+
+ if (isRunning) {
+ return;
+ }
+
+ const loadedMessages = reconcileInterruptedAssistantMessages(messagesResponse.messages).map(
+ convertToThreadMessage
+ );
+ if (messages.length > 0 && loadedMessages.length < messages.length) {
+ return;
+ }
+ setMessages(loadedMessages);
+
+ tokenUsageStore.clear();
+ const restoredDocsMap: Record = {};
+ for (const msg of messagesResponse.messages) {
+ if (msg.token_usage) {
+ tokenUsageStore.set(`msg-${msg.id}`, msg.token_usage as TokenUsageData);
+ }
+ if (msg.role === "user") {
+ const docs = extractMentionedDocuments(msg.content);
+ if (docs.length > 0) {
+ restoredDocsMap[`msg-${msg.id}`] = docs;
+ }
+ }
+ }
+ setMessageDocumentsMap(restoredDocsMap);
+ hydratedMessagesRef.current = { threadId: activeThreadId, data: messagesResponse };
+ }, [
+ activeThreadId,
+ isRunning,
+ messages.length,
+ setMessageDocumentsMap,
+ threadMessagesQuery.data,
+ tokenUsageStore,
+ ]);
+
+ useEffect(() => {
+ const loadError = threadDetailQuery.error ?? threadMessagesQuery.error;
+ if (!activeThreadId || !loadError) return;
+ if (handledLoadErrorThreadRef.current === activeThreadId) return;
+
+ handledLoadErrorThreadRef.current = activeThreadId;
+ console.error("[NewChatPage] Failed to load thread:", loadError);
+
+ if (loadError instanceof NotFoundError) {
+ removeChatTab(activeThreadId);
+ if (typeof window !== "undefined") {
+ window.history.replaceState(null, "", `/dashboard/${workspaceId}/new-chat`);
+ }
+ setThreadId(null);
+ setCurrentThread(null);
+ setMessages([]);
+ toast.error("This chat was deleted.");
+ return;
+ }
+
+ toast.error("Failed to load chat. Please try again.");
+ }, [
+ activeThreadId,
+ removeChatTab,
+ workspaceId,
+ threadDetailQuery.error,
+ threadMessagesQuery.error,
+ ]);
+
+ // Prefetch document titles for @ mention picker
+ // Runs when user lands on page so data is ready when they type @
+ useEffect(() => {
+ if (!workspaceId) return;
+
+ const prefetchParams = {
+ workspace_id: workspaceId,
+ page: 0,
+ page_size: 20,
+ };
+
+ queryClient.prefetchQuery({
+ queryKey: ["document-titles", prefetchParams],
+ queryFn: () => documentsApiService.searchDocumentTitles({ queryParams: prefetchParams }),
+ staleTime: 60 * 1000,
+ });
+ }, [workspaceId, queryClient]);
+
+ // Handle scroll to comment from URL query params (e.g., from inbox item click)
+ // Read from window.location.search inside the effect instead of subscribing via
+ // useSearchParams() — avoids re-rendering this heavy component tree on every
+ // unrelated query-string change. (Vercel Best Practice: rerender-defer-reads 5.2)
+ useEffect(() => {
+ const readAndApplyCommentId = () => {
+ const params = new URLSearchParams(window.location.search);
+ const raw = params.get("commentId");
+ if (raw && activeThreadId) {
+ const commentId = Number.parseInt(raw, 10);
+ if (!Number.isNaN(commentId)) {
+ setTargetCommentId(commentId);
+ }
+ }
+ };
+
+ readAndApplyCommentId();
+
+ // Also respond to SPA navigations (back/forward) that change the query string
+ window.addEventListener("popstate", readAndApplyCommentId);
+
+ // Cleanup on unmount or when navigating away
+ return () => {
+ window.removeEventListener("popstate", readAndApplyCommentId);
+ clearTargetCommentId();
+ };
+ }, [activeThreadId, setTargetCommentId, clearTargetCommentId]);
+
+ // Sync current thread state to atom
+ useEffect(() => {
+ if (!currentThread) {
+ if (activeThreadId) {
+ return;
+ }
+ setCurrentThreadMetadata({
+ id: null,
+ workspaceId: null,
+ visibility: null,
+ hasComments: false,
+ });
+ return;
+ }
+
+ const visibility =
+ currentThreadState.id === currentThread.id && currentThreadState.visibility !== null
+ ? currentThreadState.visibility
+ : currentThread.visibility;
+
+ setCurrentThreadMetadata({
+ id: currentThread.id,
+ workspaceId: currentThread.workspace_id ?? workspaceId,
+ visibility,
+ hasComments: currentThread.has_comments ?? false,
+ });
+ }, [
+ activeThreadId,
+ currentThread,
+ currentThreadState.id,
+ currentThreadState.visibility,
+ workspaceId,
+ setCurrentThreadMetadata,
+ ]);
+
+ // Cleanup on unmount - abort any in-flight requests
+ useEffect(() => {
+ return () => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ }
+ };
+ }, []);
+
+ // Cancel ongoing request
+ const cancelRun = useCallback(async () => {
+ if (threadId) {
+ try {
+ const response = await authenticatedFetch(
+ buildBackendUrl(`/api/v1/threads/${threadId}/cancel-active-turn`),
+ {
+ method: "POST",
+ }
+ );
+ if (response.ok) {
+ const payload = (await response.json()) as {
+ error_code?: string;
+ };
+ if (payload.error_code === "TURN_CANCELLING") {
+ recentCancelRequestedAtRef.current = Date.now();
+ }
+ }
+ } catch (error) {
+ console.warn("[NewChatPage] Failed to signal cancel-active-turn:", error);
+ }
+ }
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ }
+ setIsRunning(false);
+ }, [threadId]);
+
+ // Handle new message from user
+ const onNew = useCallback(
+ async (message: AppendMessage) => {
+ // Abort any previous streaming request to prevent race conditions
+ // when user sends a second query while the first is still streaming
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ }
+
+ // Prefer the submit-time snapshot; fall back to the live atom
+ // for the send-button path.
+ const submittedSnapshot = jotaiStore.get(submittedMentionsAtom);
+ jotaiStore.set(submittedMentionsAtom, null);
+ const activeMentions = submittedSnapshot ?? mentionedDocuments;
+ const mentionPayload = deriveMentionedPayload(activeMentions);
+ if (activeMentions.length > 0) {
+ setMentionedDocuments([]);
+ }
+
+ const urlsSnapshot = [...pendingUserImageUrls];
+ const { userQuery, userImages } = extractUserTurnForNewChatApi(message, urlsSnapshot);
+
+ if (!userQuery.trim() && userImages.length === 0) return;
+
+ // Lazy thread creation: create thread on first message if it doesn't exist
+ let currentThreadId = threadId;
+ let isNewThread = false;
+ if (!currentThreadId) {
+ try {
+ const newThread = await createThread(workspaceId, "New Chat");
+ currentThreadId = newThread.id;
+ setThreadId(currentThreadId);
+ // Set currentThread so share button in header appears immediately
+ setCurrentThread(newThread);
+ queryClient.setQueryData(cacheKeys.threads.detail(newThread.id), newThread);
+ queryClient.setQueryData(cacheKeys.threads.messages(newThread.id), { messages: [] });
+
+ // Track chat creation
+ trackChatCreated(workspaceId, currentThreadId);
+
+ isNewThread = true;
+ // Update URL silently using browser API (not router.replace) to avoid
+ // interrupting the ongoing fetch/streaming with React navigation
+ window.history.replaceState(
+ null,
+ "",
+ `/dashboard/${workspaceId}/new-chat/${currentThreadId}`
+ );
+ } catch (error) {
+ console.error("[NewChatPage] Failed to create thread:", error);
+ await handleChatFailure({
+ error: tagPreAcceptSendFailure(error),
+ flow: "new",
+ threadId: currentThreadId,
+ assistantMsgId: "no-persist-assistant",
+ });
+ return;
+ }
+ }
+
+ if (urlsSnapshot.length > 0) {
+ setPendingUserImageUrls((prev) => prev.filter((u) => !urlsSnapshot.includes(u)));
+ }
+
+ // Add user message to state. Mutable because the SSE
+ // ``data-user-message-id`` handler (below) renames this
+ // optimistic id to the canonical ``msg-{db_id}`` once the
+ // backend's ``persist_user_turn`` resolves the row, and
+ // the in-stream flush / interrupt closures need to see
+ // the post-rename value via this live ``let`` binding.
+ let userMsgId = `msg-user-${Date.now()}`;
+
+ // Always include author metadata so the UI layer can decide visibility
+ const authorMetadata = currentUser
+ ? {
+ custom: {
+ author: {
+ displayName: currentUser.display_name ?? null,
+ avatarUrl: currentUser.avatar_url ?? null,
+ },
+ },
+ }
+ : undefined;
+
+ const existingImageUrls = new Set(
+ message.content
+ .filter(
+ (p): p is { type: "image"; image: string } =>
+ typeof p === "object" &&
+ p !== null &&
+ "type" in p &&
+ p.type === "image" &&
+ "image" in p
+ )
+ .map((p) => p.image)
+ );
+ const extraImageParts = urlsSnapshot
+ .filter((u) => !existingImageUrls.has(u))
+ .map((image) => ({ type: "image" as const, image }));
+ const userDisplayContent = [...message.content, ...extraImageParts];
+
+ const userMessage: ThreadMessageLike = {
+ id: userMsgId,
+ role: "user",
+ content: userDisplayContent,
+ createdAt: new Date(),
+ metadata: authorMetadata,
+ };
+ setMessages((prev) => [...prev, userMessage]);
+
+ // Track message sent
+ trackChatMessageSent(workspaceId, currentThreadId, {
+ hasAttachments: userImages.length > 0,
+ hasMentionedDocuments:
+ mentionPayload.document_ids.length > 0 ||
+ mentionPayload.folder_ids.length > 0 ||
+ mentionPayload.connector_ids.length > 0,
+ messageLength: userQuery.length,
+ });
+
+ // Collect unique mention chips for display & persistence.
+ // The ``kind`` field is forwarded to the backend
+ // so the persisted ``mentioned-documents`` content part
+ // can render the correct chip type on reload.
+ const allMentionedDocs: MentionedDocumentInfo[] = [];
+ const seenDocKeys = new Set();
+ for (const doc of activeMentions) {
+ const key = getMentionDocKey(doc);
+ if (seenDocKeys.has(key)) continue;
+ seenDocKeys.add(key);
+ allMentionedDocs.push(doc);
+ }
+
+ if (allMentionedDocs.length > 0) {
+ setMessageDocumentsMap((prev) => ({
+ ...prev,
+ [userMsgId]: allMentionedDocs,
+ }));
+ }
+
+ // Start streaming response
+ setIsRunning(true);
+ const controller = new AbortController();
+ abortControllerRef.current = controller;
+
+ // Prepare assistant message. Mutable for the same reason
+ // as ``userMsgId`` above — the ``data-assistant-message-id``
+ // SSE handler reassigns this once
+ // ``persist_assistant_shell`` returns its canonical id.
+ let assistantMsgId = `msg-assistant-${Date.now()}`;
+ const currentThinkingSteps = new Map();
+ const contentPartsState: ContentPartsState = {
+ contentParts: [],
+ currentTextPartIndex: -1,
+ currentReasoningPartIndex: -1,
+ toolCallIndices: new Map(),
+ };
+ const { contentParts } = contentPartsState;
+ let wasInterrupted = false;
+ let newAccepted = false;
+ let streamBatcher: FrameBatchedUpdater | null = null;
+
+ try {
+ const selection = await getAgentFilesystemSelection(workspaceId, {
+ localFilesystemEnabled,
+ });
+ if (
+ selection.filesystem_mode === "desktop_local_folder" &&
+ (!selection.local_filesystem_mounts || selection.local_filesystem_mounts.length === 0)
+ ) {
+ toast.error("Select a local folder before using Local Folder mode.");
+ return;
+ }
+
+ // Build message history for context
+ const messageHistory = messages
+ .filter((m) => m.role === "user" || m.role === "assistant")
+ .map((m) => {
+ let text = "";
+ for (const part of m.content) {
+ if (typeof part === "object" && part.type === "text" && "text" in part) {
+ text += part.text;
+ }
+ }
+ return { role: m.role, content: text };
+ })
+ .filter((m) => m.content.length > 0);
+
+ // Backend expects each mention kind in its own payload bucket.
+ const hasDocumentIds = mentionPayload.document_ids.length > 0;
+ const hasFolderIds = mentionPayload.folder_ids.length > 0;
+ const hasConnectorIds = mentionPayload.connector_ids.length > 0;
+ const hasThreadIds = mentionPayload.thread_ids.length > 0;
+
+ const response = await fetchWithTurnCancellingRetry(() =>
+ authenticatedFetch(buildBackendUrl("/api/v1/new_chat"), {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ chat_id: currentThreadId,
+ user_query: userQuery.trim(),
+ workspace_id: workspaceId,
+ filesystem_mode: selection.filesystem_mode,
+ client_platform: selection.client_platform,
+ local_filesystem_mounts: selection.local_filesystem_mounts,
+ messages: messageHistory,
+ mentioned_document_ids: hasDocumentIds ? mentionPayload.document_ids : undefined,
+ mentioned_folder_ids: hasFolderIds ? mentionPayload.folder_ids : undefined,
+ mentioned_connector_ids: hasConnectorIds ? mentionPayload.connector_ids : undefined,
+ mentioned_connectors: hasConnectorIds ? mentionPayload.connectors : undefined,
+ mentioned_thread_ids: hasThreadIds ? mentionPayload.thread_ids : undefined,
+ // Full mention metadata so the backend can persist a
+ // ``mentioned-documents`` ContentPart on the user message.
+ mentioned_documents: allMentionedDocs.length > 0 ? allMentionedDocs : undefined,
+ disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
+ ...(userImages.length > 0 ? { user_images: userImages } : {}),
+ }),
+ signal: controller.signal,
+ })
+ );
+
+ if (!response.ok) {
+ throw await toHttpResponseError(response);
+ }
+ newAccepted = true;
+ setMessages((prev) => [
+ ...prev,
+ {
+ id: assistantMsgId,
+ role: "assistant",
+ content: [{ type: "text", text: "" }],
+ createdAt: new Date(),
+ },
+ ]);
+
+ const flushMessages = () => {
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === assistantMsgId
+ ? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) }
+ : m
+ )
+ );
+ };
+ const { batcher, scheduleFlush, forceFlush } = createStreamFlushHelpers(flushMessages);
+ streamBatcher = batcher;
+
+ await consumeSseEvents(response, async (parsed) => {
+ if (
+ processSharedStreamEvent(parsed, {
+ contentPartsState,
+ toolsWithUI,
+ currentThinkingSteps,
+ scheduleFlush,
+ forceFlush,
+ onTokenUsage: (data) => {
+ tokenUsageStore.set(assistantMsgId, data);
+ },
+ onTurnStatus: (data) => {
+ if (data.status === "cancelling") {
+ recentCancelRequestedAtRef.current = Date.now();
+ }
+ },
+ })
+ ) {
+ return;
+ }
+ switch (parsed.type) {
+ case "data-thread-title-update": {
+ const titleData = parsed.data as { threadId: number; title: string };
+ if (titleData?.title && titleData?.threadId === currentThreadId) {
+ setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev));
+ updateChatTabTitle({ chatId: currentThreadId, title: titleData.title });
+ queryClient.setQueriesData(
+ { queryKey: ["threads", String(workspaceId)] },
+ (old) => {
+ if (!old) return old;
+ const updateTitle = (list: ThreadListItem[]) =>
+ list.map((t) =>
+ t.id === titleData.threadId ? { ...t, title: titleData.title } : t
+ );
+ return {
+ ...old,
+ threads: updateTitle(old.threads),
+ archived_threads: updateTitle(old.archived_threads),
+ };
+ }
+ );
+ }
+ break;
+ }
+
+ case "data-documents-updated": {
+ const docEvent = parsed.data as {
+ action: string;
+ document: AgentCreatedDocument;
+ };
+ if (docEvent?.document?.id) {
+ setAgentCreatedDocuments((prev) => {
+ if (prev.some((d) => d.id === docEvent.document.id)) return prev;
+ return [...prev, docEvent.document];
+ });
+ }
+ break;
+ }
+
+ case "data-interrupt-request": {
+ wasInterrupted = true;
+ const interruptData = parsed.data as Record;
+ const actionRequests = (interruptData.action_requests ?? []) as Array<{
+ name: string;
+ args: Record;
+ }>;
+ const paired = pairBundleToolCallIds(
+ contentPartsState.toolCallIndices,
+ contentPartsState.contentParts,
+ actionRequests
+ );
+ const bundleToolCallIds: string[] = [];
+ for (let i = 0; i < actionRequests.length; i++) {
+ const action = actionRequests[i];
+ let targetTcId = paired[i];
+ if (!targetTcId) {
+ targetTcId = freshSynthToolCallId(
+ contentPartsState.toolCallIndices,
+ action.name,
+ i
+ );
+ addToolCall(
+ contentPartsState,
+ toolsWithUI,
+ targetTcId,
+ action.name,
+ action.args,
+ true
+ );
+ }
+ updateToolCall(contentPartsState, targetTcId, {
+ result: { __interrupt__: true, ...interruptData },
+ });
+ bundleToolCallIds.push(targetTcId);
+ }
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === assistantMsgId
+ ? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) }
+ : m
+ )
+ );
+ if (currentThreadId) {
+ // ``tool_call_id`` is stamped on the backend by
+ // ``checkpointed_subagent_middleware``. Without it we
+ // can't address the paused subagent on resume — skip
+ // rather than fabricate a synthetic key.
+ const interruptId = String(interruptData.tool_call_id ?? "");
+ if (interruptId) {
+ const incoming: PendingInterruptState = {
+ interruptId,
+ threadId: currentThreadId,
+ assistantMsgId,
+ interruptData,
+ bundleToolCallIds,
+ };
+ setPendingInterrupts((prev) => {
+ const without = prev.filter((p) => p.interruptId !== interruptId);
+ return [...without, incoming];
+ });
+ }
+ }
+ break;
+ }
+
+ case "data-action-log": {
+ applyActionLogSse(queryClient, currentThreadId, workspaceId, parsed.data);
+ break;
+ }
+
+ case "data-action-log-updated": {
+ applyActionLogUpdatedSse(
+ queryClient,
+ currentThreadId,
+ parsed.data.id,
+ parsed.data.reversible
+ );
+ break;
+ }
+
+ case "data-turn-info": {
+ const turnId = readStreamedChatTurnId(parsed.data);
+ if (turnId) {
+ setMessages((prev) =>
+ applyTurnIdToAssistantMessageList(prev, assistantMsgId, turnId)
+ );
+ }
+ break;
+ }
+
+ case "data-user-message-id": {
+ // Server-authoritative user message id resolved by
+ // ``persist_user_turn`` (or recovered via ON CONFLICT).
+ // Rename the optimistic ``msg-user-XXX`` placeholder to
+ // the canonical ``msg-{db_id}`` so DB-id-gated UI
+ // (comments, edit-from-this-message) unlocks immediately,
+ // migrate the local mentioned-documents map, and reassign
+ // the closure variable so all downstream
+ // ``m.id === userMsgId`` checks see the new value.
+ const parsedMsg = readStreamedMessageId(parsed.data);
+ if (!parsedMsg) break;
+ const newUserMsgId = `msg-${parsedMsg.messageId}`;
+ const oldUserMsgId = userMsgId;
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === oldUserMsgId
+ ? mergeChatTurnIdIntoMessage({ ...m, id: newUserMsgId }, parsedMsg.turnId)
+ : m
+ )
+ );
+ if (allMentionedDocs.length > 0) {
+ setMessageDocumentsMap((prev) => {
+ if (!(oldUserMsgId in prev)) {
+ return { ...prev, [newUserMsgId]: allMentionedDocs };
+ }
+ const { [oldUserMsgId]: _removed, ...rest } = prev;
+ return { ...rest, [newUserMsgId]: allMentionedDocs };
+ });
+ }
+ userMsgId = newUserMsgId;
+ if (isNewThread) {
+ // First user-side row landed in ``new_chat_messages``;
+ // refresh the sidebar so the freshly-bumped
+ // ``thread.updated_at`` reorders this thread.
+ queryClient.invalidateQueries({
+ queryKey: ["threads", String(workspaceId)],
+ });
+ }
+ break;
+ }
+
+ case "data-assistant-message-id": {
+ // Server-authoritative assistant message id resolved
+ // by ``persist_assistant_shell``. Rename the optimistic
+ // id, migrate ``tokenUsageStore`` so any pending
+ // ``data-token-usage`` payload binds to the new id,
+ // remap any in-flight ``pendingInterrupts`` entries,
+ // and reassign the closure variable so the in-stream
+ // flush callback (line ~1074) keeps writing to the
+ // renamed message.
+ const parsedMsg = readStreamedMessageId(parsed.data);
+ if (!parsedMsg) break;
+ const newAssistantMsgId = `msg-${parsedMsg.messageId}`;
+ const oldAssistantMsgId = assistantMsgId;
+ tokenUsageStore.rename(oldAssistantMsgId, newAssistantMsgId);
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === oldAssistantMsgId
+ ? mergeChatTurnIdIntoMessage({ ...m, id: newAssistantMsgId }, parsedMsg.turnId)
+ : m
+ )
+ );
+ setPendingInterrupts((prev) =>
+ prev.map((p) =>
+ p.assistantMsgId === oldAssistantMsgId
+ ? { ...p, assistantMsgId: newAssistantMsgId }
+ : p
+ )
+ );
+ assistantMsgId = newAssistantMsgId;
+ break;
+ }
+ }
+ });
+
+ batcher.flush();
+
+ // Server-authoritative persistence: ``stream_new_chat``
+ // already wrote the user row in ``persist_user_turn``
+ // (the FE renamed the optimistic id mid-stream via
+ // ``data-user-message-id``) and finalises the assistant
+ // row in ``finalize_assistant_turn`` from a shielded
+ // ``finally`` block. Nothing left for the FE to persist
+ // here — track the response and unblock the UI.
+ if (contentParts.length > 0 && !wasInterrupted) {
+ trackChatResponseReceived(workspaceId, currentThreadId);
+ }
+ } catch (error) {
+ streamBatcher?.dispose();
+ await handleStreamTerminalError({
+ error,
+ flow: "new",
+ threadId: currentThreadId,
+ assistantMsgId,
+ accepted: newAccepted,
+ // Server-side ``finalize_assistant_turn`` runs from a
+ // shielded ``anyio.CancelScope(shield=True)`` finally
+ // block, so partial content (incl. abort-mid-stream)
+ // is already persisted by the BE for the assistant
+ // row, and ``persist_user_turn`` ran before any LLM
+ // call. The FE's only remaining responsibility on
+ // abort / accepted-stream-error is to surface the
+ // error toast (handled by ``handleStreamTerminalError``
+ // itself).
+ onPreAcceptFailure: async () => {
+ // Pre-accept failure means the BE never accepted the
+ // request — no server-side persistence ran. Roll
+ // back the optimistic UI insertions we made before
+ // the fetch so the user message and any local
+ // mentioned-docs metadata don't linger.
+ setMessages((prev) => prev.filter((m) => m.id !== userMsgId));
+ setMessageDocumentsMap((prev) => {
+ if (!(userMsgId in prev)) return prev;
+ const { [userMsgId]: _removed, ...rest } = prev;
+ return rest;
+ });
+ },
+ });
+ } finally {
+ setIsRunning(false);
+ abortControllerRef.current = null;
+ if (currentThreadId) {
+ void queryClient.invalidateQueries({
+ queryKey: cacheKeys.threads.messages(currentThreadId),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: cacheKeys.threads.detail(currentThreadId),
+ });
+ }
+ }
+ },
+ [
+ threadId,
+ workspaceId,
+ messages,
+ jotaiStore,
+ mentionedDocuments,
+ setMentionedDocuments,
+ setMessageDocumentsMap,
+ setAgentCreatedDocuments,
+ queryClient,
+ currentUser,
+ localFilesystemEnabled,
+ disabledTools,
+ updateChatTabTitle,
+ tokenUsageStore,
+ pendingUserImageUrls,
+ setPendingUserImageUrls,
+ fetchWithTurnCancellingRetry,
+ handleStreamTerminalError,
+ handleChatFailure,
+ ]
+ );
+
+ const handleResume = useCallback(
+ async (
+ decisions: Array<{
+ type: string;
+ message?: string;
+ edited_action?: { name: string; args: Record };
+ }>
+ ) => {
+ if (pendingInterrupts.length === 0) return;
+ // All cards in this turn share the same threadId/assistantMsgId
+ // (they're siblings of one parent agent step), so reading from
+ // the first entry is safe.
+ const resumeThreadId = pendingInterrupts[0].threadId;
+ // Destructured separately as ``let`` so the SSE
+ // ``data-assistant-message-id`` handler (resume always
+ // allocates a fresh server-side row) can rename it to
+ // the canonical ``msg-{db_id}`` mid-stream.
+ let assistantMsgId = pendingInterrupts[0].assistantMsgId;
+ // Concatenate every card's tool-call ids in pendingInterrupts order;
+ // this matches the ``decisions`` ordering produced by
+ // ``handleApprovalSubmit`` and the backend slicer's traversal of
+ // ``state.interrupts``.
+ const allBundleToolCallIds = pendingInterrupts.flatMap((p) => p.bundleToolCallIds);
+ setPendingInterrupts([]);
+ stagedDecisionsByInterruptIdRef.current.clear();
+ setIsRunning(true);
+
+ const controller = new AbortController();
+ abortControllerRef.current = controller;
+
+ const currentThinkingSteps = new Map();
+
+ const contentPartsState: ContentPartsState = {
+ contentParts: [],
+ currentTextPartIndex: -1,
+ currentReasoningPartIndex: -1,
+ toolCallIndices: new Map(),
+ };
+ const { contentParts, toolCallIndices } = contentPartsState;
+ let resumeAccepted = false;
+ let streamBatcher: FrameBatchedUpdater | null = null;
+
+ const existingMsg = messages.find((m) => m.id === assistantMsgId);
+ if (existingMsg && Array.isArray(existingMsg.content)) {
+ // See ``ContentPartsState.suppressStepSeparators`` doc.
+ contentPartsState.suppressStepSeparators = true;
+ for (const part of existingMsg.content) {
+ if (typeof part === "object" && part !== null) {
+ const p = part as Record;
+ if (p.type === "text") {
+ contentParts.push({ type: "text", text: String(p.text ?? "") });
+ contentPartsState.currentTextPartIndex = contentParts.length - 1;
+ } else if (p.type === "tool-call") {
+ toolCallIndices.set(String(p.toolCallId), contentParts.length);
+ contentParts.push({
+ type: "tool-call",
+ toolCallId: String(p.toolCallId),
+ toolName: String(p.toolName),
+ args: (p.args as Record) ?? {},
+ result: p.result as unknown,
+ // argsText: assistant-ui prefers it over
+ // JSON.stringify(args), so restoring it keeps
+ // pretty-printed JSON across reloads.
+ ...(typeof p.argsText === "string" ? { argsText: p.argsText } : {}),
+ ...(typeof p.langchainToolCallId === "string"
+ ? { langchainToolCallId: p.langchainToolCallId }
+ : {}),
+ // metadata: spanId / thinkingStepId drive the
+ // timeline's step↔tool join. Dropping these
+ // here orphans every rehydrated tool-call.
+ ...(p.metadata && typeof p.metadata === "object"
+ ? { metadata: p.metadata as Record }
+ : {}),
+ });
+ contentPartsState.currentTextPartIndex = -1;
+ } else if (p.type === "data-thinking-steps") {
+ const stepsData = p.data as { steps: ThinkingStepData[] } | undefined;
+ contentParts.push({
+ type: "data-thinking-steps",
+ data: { steps: stepsData?.steps ?? [] },
+ });
+ for (const step of stepsData?.steps ?? []) {
+ currentThinkingSteps.set(step.id, step);
+ }
+ }
+ }
+ }
+ }
+
+ // Apply each decision to its own card by toolCallId so mixed
+ // bundles (approve/edit/reject) and multi-edit bundles do not
+ // collapse onto ``decisions[0]``. Cards outside the bundle are
+ // untouched. Mirrors the host ``hitl-decision`` handler.
+ const decisionByTcId = new Map();
+ const tcIds = allBundleToolCallIds;
+ if (decisions.length === tcIds.length) {
+ for (let i = 0; i < tcIds.length; i++) decisionByTcId.set(tcIds[i], decisions[i]);
+ }
+ if (decisionByTcId.size > 0) {
+ for (const part of contentParts) {
+ if (part.type !== "tool-call") continue;
+ const tcId = part.toolCallId as string | undefined;
+ const d = tcId ? decisionByTcId.get(tcId) : undefined;
+ if (!d) continue;
+ if (typeof part.result !== "object" || part.result === null) continue;
+ if (!("__interrupt__" in (part.result as Record))) continue;
+ const decided = d.type;
+ if (decided === "edit" && d.edited_action) {
+ const mergedArgs = { ...part.args, ...d.edited_action.args };
+ part.args = mergedArgs;
+ // Sync argsText so the rendered card shows the
+ // edited inputs (assistant-ui prefers it over
+ // JSON.stringify(args)).
+ part.argsText = JSON.stringify(mergedArgs, null, 2);
+ }
+ part.result = {
+ ...(part.result as Record),
+ __decided__: decided,
+ };
+ }
+ }
+
+ try {
+ const selection = await getAgentFilesystemSelection(workspaceId, {
+ localFilesystemEnabled,
+ });
+ const response = await fetchWithTurnCancellingRetry(() =>
+ authenticatedFetch(buildBackendUrl(`/api/v1/threads/${resumeThreadId}/resume`), {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ workspace_id: workspaceId,
+ decisions,
+ disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
+ filesystem_mode: selection.filesystem_mode,
+ client_platform: selection.client_platform,
+ local_filesystem_mounts: selection.local_filesystem_mounts,
+ }),
+ signal: controller.signal,
+ })
+ );
+
+ if (!response.ok) {
+ throw await toHttpResponseError(response);
+ }
+ resumeAccepted = true;
+
+ const flushMessages = () => {
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === assistantMsgId
+ ? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) }
+ : m
+ )
+ );
+ };
+ const { batcher, scheduleFlush, forceFlush } = createStreamFlushHelpers(flushMessages);
+ streamBatcher = batcher;
+
+ await consumeSseEvents(response, async (parsed) => {
+ if (
+ processSharedStreamEvent(parsed, {
+ contentPartsState,
+ toolsWithUI,
+ currentThinkingSteps,
+ scheduleFlush,
+ forceFlush,
+ onTokenUsage: (data) => {
+ tokenUsageStore.set(assistantMsgId, data);
+ },
+ onTurnStatus: (data) => {
+ if (data.status === "cancelling") {
+ recentCancelRequestedAtRef.current = Date.now();
+ }
+ },
+ })
+ ) {
+ return;
+ }
+ switch (parsed.type) {
+ case "data-interrupt-request": {
+ const interruptData = parsed.data as Record;
+ const actionRequests = (interruptData.action_requests ?? []) as Array<{
+ name: string;
+ args: Record;
+ }>;
+ const paired = pairBundleToolCallIds(
+ contentPartsState.toolCallIndices,
+ contentPartsState.contentParts,
+ actionRequests
+ );
+ const bundleToolCallIds: string[] = [];
+ for (let i = 0; i < actionRequests.length; i++) {
+ const action = actionRequests[i];
+ let targetTcId = paired[i];
+ if (!targetTcId) {
+ targetTcId = freshSynthToolCallId(
+ contentPartsState.toolCallIndices,
+ action.name,
+ i
+ );
+ addToolCall(
+ contentPartsState,
+ toolsWithUI,
+ targetTcId,
+ action.name,
+ action.args,
+ true
+ );
+ }
+ updateToolCall(contentPartsState, targetTcId, {
+ result: { __interrupt__: true, ...interruptData },
+ });
+ bundleToolCallIds.push(targetTcId);
+ }
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === assistantMsgId
+ ? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) }
+ : m
+ )
+ );
+ {
+ const interruptId = String(interruptData.tool_call_id ?? "");
+ if (interruptId) {
+ const incoming: PendingInterruptState = {
+ interruptId,
+ threadId: resumeThreadId,
+ assistantMsgId,
+ interruptData,
+ bundleToolCallIds,
+ };
+ setPendingInterrupts((prev) => {
+ const without = prev.filter((p) => p.interruptId !== interruptId);
+ return [...without, incoming];
+ });
+ }
+ }
+ break;
+ }
+
+ case "data-action-log": {
+ applyActionLogSse(queryClient, resumeThreadId, workspaceId, parsed.data);
+ break;
+ }
+
+ case "data-action-log-updated": {
+ applyActionLogUpdatedSse(
+ queryClient,
+ resumeThreadId,
+ parsed.data.id,
+ parsed.data.reversible
+ );
+ break;
+ }
+
+ case "data-turn-info": {
+ const turnId = readStreamedChatTurnId(parsed.data);
+ if (turnId) {
+ setMessages((prev) =>
+ applyTurnIdToAssistantMessageList(prev, assistantMsgId, turnId)
+ );
+ }
+ break;
+ }
+
+ case "data-assistant-message-id": {
+ // Resume always allocates a fresh ``new_chat_messages``
+ // row anchored to a new ``turn_id`` (the original
+ // interrupted turn's row stays as-is), so this is a
+ // real id swap. Rename the optimistic placeholder to
+ // ``msg-{db_id}`` and reassign closure state. Resume
+ // does NOT emit ``data-user-message-id`` — the user
+ // row belongs to the original interrupted turn.
+ const parsedMsg = readStreamedMessageId(parsed.data);
+ if (!parsedMsg) break;
+ const newAssistantMsgId = `msg-${parsedMsg.messageId}`;
+ const oldAssistantMsgId = assistantMsgId;
+ tokenUsageStore.rename(oldAssistantMsgId, newAssistantMsgId);
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === oldAssistantMsgId
+ ? mergeChatTurnIdIntoMessage({ ...m, id: newAssistantMsgId }, parsedMsg.turnId)
+ : m
+ )
+ );
+ assistantMsgId = newAssistantMsgId;
+ break;
+ }
+ }
+ });
+
+ batcher.flush();
+
+ // Server-authoritative persistence: ``stream_resume_chat``
+ // finalises the assistant row in
+ // ``finalize_assistant_turn`` from a shielded
+ // ``finally`` block (covers both happy-path and
+ // abort-mid-stream). FE has no remaining persistence
+ // work here.
+ } catch (error) {
+ streamBatcher?.dispose();
+ await handleStreamTerminalError({
+ error,
+ flow: "resume",
+ threadId: resumeThreadId,
+ assistantMsgId,
+ accepted: resumeAccepted,
+ });
+ } finally {
+ setIsRunning(false);
+ abortControllerRef.current = null;
+ void queryClient.invalidateQueries({
+ queryKey: cacheKeys.threads.messages(resumeThreadId),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: cacheKeys.threads.detail(resumeThreadId),
+ });
+ }
+ },
+ [
+ pendingInterrupts,
+ messages,
+ workspaceId,
+ localFilesystemEnabled,
+ disabledTools,
+ queryClient,
+ tokenUsageStore,
+ fetchWithTurnCancellingRetry,
+ handleStreamTerminalError,
+ ]
+ );
+
+ useEffect(() => {
+ const handler = (e: Event) => {
+ const detail = (e as CustomEvent).detail as {
+ decisions: Array<{
+ type: string;
+ message?: string;
+ edited_action?: { name: string; args: Record };
+ }>;
+ };
+ if (!detail?.decisions || pendingInterrupts.length === 0) return;
+ const incoming = detail.decisions;
+ if (incoming.length === 0) return;
+ // Concatenated tool-call ids across every pending card, in the
+ // order ``handleApprovalSubmit`` produced ``incoming``.
+ const tcIds = pendingInterrupts.flatMap((p) => p.bundleToolCallIds);
+ const N = tcIds.length;
+
+ // Refuse rather than silently broadcast or drop. The orchestrator
+ // only fires ``hitl-decision`` once every pending card has
+ // submitted, so a count mismatch indicates a contract drift
+ // (and would later make the backend slicer raise).
+ if (incoming.length !== N) {
+ toast.error(
+ `Cannot resume: ${incoming.length} decision(s) submitted for ${N} pending actions.`
+ );
+ return;
+ }
+
+ const byTcId = new Map();
+ const submittedDecisions: typeof incoming = [];
+ for (let i = 0; i < tcIds.length; i++) {
+ const tcId = tcIds[i];
+ const decision = incoming[i];
+ if (tcId === undefined || decision === undefined) {
+ toast.error(
+ `Cannot resume: ${incoming.length} decision(s) submitted for ${N} pending actions.`
+ );
+ return;
+ }
+ byTcId.set(tcId, decision);
+ submittedDecisions.push(decision);
+ }
+
+ // All pending cards belong to the same assistant message, so a
+ // single content-update pass suffices.
+ const targetAssistantMsgId = pendingInterrupts[0].assistantMsgId;
+ setMessages((prev) =>
+ prev.map((m) => {
+ if (m.id !== targetAssistantMsgId) return m;
+ const parts = m.content as unknown as Array>;
+ const newContent = parts.map((part) => {
+ const tcId = part.toolCallId as string | undefined;
+ const d = tcId ? byTcId.get(tcId) : undefined;
+ if (!d || part.type !== "tool-call") return part;
+ if (typeof part.result !== "object" || part.result === null) return part;
+ if (!("__interrupt__" in (part.result as Record))) return part;
+ const decided = d.type;
+ if (decided === "edit" && d.edited_action) {
+ return {
+ ...part,
+ args: d.edited_action.args,
+ // Sync argsText so the card renders the edited
+ // inputs (assistant-ui prefers it over JSON.stringify).
+ argsText: JSON.stringify(d.edited_action.args, null, 2),
+ result: {
+ ...(part.result as Record),
+ __decided__: decided,
+ },
+ };
+ }
+ return {
+ ...part,
+ result: {
+ ...(part.result as Record),
+ __decided__: decided,
+ },
+ };
+ });
+ return { ...m, content: newContent as unknown as ThreadMessageLike["content"] };
+ })
+ );
+ handleResume(submittedDecisions);
+ };
+ window.addEventListener("hitl-decision", handler);
+ return () => window.removeEventListener("hitl-decision", handler);
+ }, [handleResume, pendingInterrupts]);
+
+ // Convert message (pass through since already in correct format)
+ const convertMessage = useCallback(
+ (message: ThreadMessageLike): ThreadMessageLike => message,
+ []
+ );
+
+ /**
+ * Handle regeneration (edit or reload) by calling the regenerate endpoint
+ * and streaming the response. This rewinds the LangGraph checkpointer state.
+ *
+ * @param newUserQuery - `null` = reload with same turn from the server. A string = edit
+ * (including an empty string when the edited turn is images-only); pass `editExtras` for images/content.
+ */
+ const handleRegenerate = useCallback(
+ async (
+ newUserQuery: string | null,
+ editExtras?: {
+ userMessageContent: ThreadMessageLike["content"];
+ userImages: NewChatUserImagePayload[];
+ sourceUserMessageId?: string;
+ },
+ editFromPosition?: {
+ /** Message id (numeric, parsed from ``msg-``) to rewind to. */
+ fromMessageId?: number | null;
+ /** When true, revert reversible downstream actions before stream. */
+ revertActions?: boolean;
+ }
+ ) => {
+ if (!threadId) {
+ toast.error("Cannot regenerate: no active chat thread");
+ return;
+ }
+
+ const isEdit = newUserQuery !== null;
+
+ // Abort any previous streaming request
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ }
+
+ // Extract the original user query BEFORE removing messages (for reload mode)
+ let userQueryToDisplay: string | undefined;
+ let originalUserMessageContent: ThreadMessageLike["content"] | null = null;
+ let originalUserMessageMetadata: ThreadMessageLike["metadata"] | undefined;
+ let sourceUserMessageId: string | undefined = editExtras?.sourceUserMessageId;
+
+ if (!isEdit) {
+ // Reload mode - find and preserve the last user message content
+ const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
+ if (lastUserMessage) {
+ sourceUserMessageId = lastUserMessage.id;
+ originalUserMessageContent = lastUserMessage.content;
+ originalUserMessageMetadata = lastUserMessage.metadata;
+ // Extract text for the API request
+ for (const part of lastUserMessage.content) {
+ if (typeof part === "object" && part.type === "text" && "text" in part) {
+ userQueryToDisplay = part.text;
+ break;
+ }
+ }
+ }
+ } else {
+ userQueryToDisplay = newUserQuery;
+ }
+
+ // Start streaming
+ setIsRunning(true);
+ const controller = new AbortController();
+ abortControllerRef.current = controller;
+
+ // Add placeholder user message if we have a new query (edit mode).
+ // Mutable for the same reason as in ``onNew`` — both ids are
+ // renamed mid-stream by the new ``data-user-message-id`` /
+ // ``data-assistant-message-id`` SSE handlers below.
+ let userMsgId = `msg-user-${Date.now()}`;
+ let assistantMsgId = `msg-assistant-${Date.now()}`;
+ const currentThinkingSteps = new Map();
+
+ const contentPartsState: ContentPartsState = {
+ contentParts: [],
+ currentTextPartIndex: -1,
+ currentReasoningPartIndex: -1,
+ toolCallIndices: new Map(),
+ };
+ const { contentParts } = contentPartsState;
+ let regenerateAccepted = false;
+ let streamBatcher: FrameBatchedUpdater | null = null;
+
+ // Add placeholder messages to UI
+ // Always add back the user message (with new query for edit, or original content for reload)
+ const userMessage: ThreadMessageLike = {
+ id: userMsgId,
+ role: "user",
+ content: isEdit
+ ? (editExtras?.userMessageContent ?? [{ type: "text", text: newUserQuery ?? "" }])
+ : originalUserMessageContent || [{ type: "text", text: userQueryToDisplay || "" }],
+ createdAt: new Date(),
+ metadata: isEdit ? undefined : originalUserMessageMetadata,
+ };
+ const sourceMentionedDocs =
+ sourceUserMessageId && messageDocumentsMap[sourceUserMessageId]
+ ? messageDocumentsMap[sourceUserMessageId]
+ : [];
+ try {
+ const selection = await getAgentFilesystemSelection(workspaceId, {
+ localFilesystemEnabled,
+ });
+ // Partition the source mentions back into doc/folder id buckets
+ // so the regenerate route can pass them to ``stream_new_chat``
+ // and the priority middleware sees the same ``[USER-MENTIONED]``
+ // priority entries the original turn did. Without this partition
+ // the regenerate flow silently dropped the agent's mention
+ // awareness — same architectural bug we fixed on the new-chat path.
+ const regenerateDocIds = sourceMentionedDocs
+ .filter((d) => d.kind === "doc")
+ .map((d) => d.id);
+ const regenerateFolderIds = sourceMentionedDocs
+ .filter((d) => d.kind === "folder")
+ .map((d) => d.id);
+ const regenerateConnectors = sourceMentionedDocs.filter((d) => d.kind === "connector");
+ const regenerateThreadIds = sourceMentionedDocs
+ .filter((d) => d.kind === "thread")
+ .map((d) => d.id);
+
+ const requestBody: Record = {
+ workspace_id: workspaceId,
+ user_query: newUserQuery,
+ disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
+ filesystem_mode: selection.filesystem_mode,
+ client_platform: selection.client_platform,
+ local_filesystem_mounts: selection.local_filesystem_mounts,
+ mentioned_document_ids: regenerateDocIds.length > 0 ? regenerateDocIds : undefined,
+ mentioned_folder_ids: regenerateFolderIds.length > 0 ? regenerateFolderIds : undefined,
+ mentioned_connector_ids:
+ regenerateConnectors.length > 0 ? regenerateConnectors.map((d) => d.id) : undefined,
+ mentioned_connectors: regenerateConnectors.length > 0 ? regenerateConnectors : undefined,
+ mentioned_thread_ids: regenerateThreadIds.length > 0 ? regenerateThreadIds : undefined,
+ // Full mention metadata for the regenerate-specific
+ // source list. Only meaningful for edit (the BE only
+ // re-persists a user row when ``user_query`` is set);
+ // reload reuses the original turn's mentioned_documents.
+ mentioned_documents: sourceMentionedDocs.length > 0 ? sourceMentionedDocs : undefined,
+ };
+ if (isEdit) {
+ requestBody.user_images = editExtras?.userImages ?? [];
+ }
+ // Explicit edit-from-arbitrary-position. Only send
+ // ``from_message_id`` / ``revert_actions`` when the
+ // caller asked for them; otherwise the backend keeps the
+ // legacy "last 2 messages" behaviour for back-compat.
+ if (editFromPosition?.fromMessageId != null) {
+ requestBody.from_message_id = editFromPosition.fromMessageId;
+ if (editFromPosition.revertActions) {
+ requestBody.revert_actions = true;
+ }
+ }
+ const response = await fetchWithTurnCancellingRetry(() =>
+ authenticatedFetch(getRegenerateUrl(threadId), {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(requestBody),
+ signal: controller.signal,
+ })
+ );
+
+ if (!response.ok) {
+ throw await toHttpResponseError(response);
+ }
+ regenerateAccepted = true;
+
+ // Only switch UI to regenerated placeholder messages after the backend accepts
+ // regenerate. This avoids local message loss when regenerate fails early (e.g. 400).
+ //
+ // When an explicit ``editFromPosition.fromMessageId`` is passed, slice from
+ // that message forward so edit-from-arbitrary-position drops every downstream
+ // message; otherwise fall back to the legacy "drop the last 2" behaviour.
+ setMessages((prev) => {
+ let base = prev;
+ if (editFromPosition?.fromMessageId != null) {
+ const targetId = `msg-${editFromPosition.fromMessageId}`;
+ const sliceIndex = prev.findIndex((m) => m.id === targetId);
+ if (sliceIndex >= 0) {
+ base = prev.slice(0, sliceIndex);
+ }
+ } else if (prev.length >= 2) {
+ base = prev.slice(0, -2);
+ }
+ return [
+ ...base,
+ userMessage,
+ {
+ id: assistantMsgId,
+ role: "assistant",
+ content: [{ type: "text", text: "" }],
+ createdAt: new Date(),
+ },
+ ];
+ });
+ if (sourceMentionedDocs.length > 0) {
+ setMessageDocumentsMap((prev) => ({
+ ...prev,
+ [userMsgId]: sourceMentionedDocs,
+ }));
+ }
+
+ const flushMessages = () => {
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === assistantMsgId
+ ? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) }
+ : m
+ )
+ );
+ };
+ const { batcher, scheduleFlush, forceFlush } = createStreamFlushHelpers(flushMessages);
+ streamBatcher = batcher;
+
+ await consumeSseEvents(response, async (parsed) => {
+ if (
+ processSharedStreamEvent(parsed, {
+ contentPartsState,
+ toolsWithUI,
+ currentThinkingSteps,
+ scheduleFlush,
+ forceFlush,
+ onTokenUsage: (data) => {
+ tokenUsageStore.set(assistantMsgId, data);
+ },
+ onTurnStatus: (data) => {
+ if (data.status === "cancelling") {
+ recentCancelRequestedAtRef.current = Date.now();
+ }
+ },
+ })
+ ) {
+ return;
+ }
+ switch (parsed.type) {
+ case "data-action-log": {
+ if (threadId !== null) {
+ applyActionLogSse(queryClient, threadId, workspaceId, parsed.data);
+ }
+ break;
+ }
+
+ case "data-action-log-updated": {
+ if (threadId !== null) {
+ applyActionLogUpdatedSse(
+ queryClient,
+ threadId,
+ parsed.data.id,
+ parsed.data.reversible
+ );
+ }
+ break;
+ }
+
+ case "data-turn-info": {
+ const turnId = readStreamedChatTurnId(parsed.data);
+ if (turnId) {
+ setMessages((prev) =>
+ applyTurnIdToAssistantMessageList(prev, assistantMsgId, turnId)
+ );
+ }
+ break;
+ }
+
+ case "data-user-message-id": {
+ // Same role as in ``onNew`` but the regenerate-specific
+ // mention metadata (``sourceMentionedDocs``) is the
+ // list to migrate onto the canonical id key.
+ const parsedMsg = readStreamedMessageId(parsed.data);
+ if (!parsedMsg) break;
+ const newUserMsgId = `msg-${parsedMsg.messageId}`;
+ const oldUserMsgId = userMsgId;
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === oldUserMsgId
+ ? mergeChatTurnIdIntoMessage({ ...m, id: newUserMsgId }, parsedMsg.turnId)
+ : m
+ )
+ );
+ if (sourceMentionedDocs.length > 0) {
+ setMessageDocumentsMap((prev) => {
+ if (!(oldUserMsgId in prev)) {
+ return { ...prev, [newUserMsgId]: sourceMentionedDocs };
+ }
+ const { [oldUserMsgId]: _removed, ...rest } = prev;
+ return { ...rest, [newUserMsgId]: sourceMentionedDocs };
+ });
+ }
+ userMsgId = newUserMsgId;
+ break;
+ }
+
+ case "data-assistant-message-id": {
+ const parsedMsg = readStreamedMessageId(parsed.data);
+ if (!parsedMsg) break;
+ const newAssistantMsgId = `msg-${parsedMsg.messageId}`;
+ const oldAssistantMsgId = assistantMsgId;
+ tokenUsageStore.rename(oldAssistantMsgId, newAssistantMsgId);
+ setMessages((prev) =>
+ prev.map((m) =>
+ m.id === oldAssistantMsgId
+ ? mergeChatTurnIdIntoMessage({ ...m, id: newAssistantMsgId }, parsedMsg.turnId)
+ : m
+ )
+ );
+ assistantMsgId = newAssistantMsgId;
+ break;
+ }
+
+ case "data-revert-results": {
+ const summary = parsed.data;
+ // failureCount must include every "not undone" bucket
+ // (not_reversible, permission_denied, failed) so the
+ // toast's "X could not be rolled back" math matches
+ // the response invariant ``total === sum(counters)``.
+ // ``skipped`` rows are batch revert artefacts (revert
+ // rows themselves) and are not user-facing failures.
+ const failureCount =
+ summary.failed + summary.not_reversible + (summary.permission_denied ?? 0);
+ if (failureCount > 0) {
+ toast.warning(
+ `Pre-revert: ${summary.reverted}/${summary.total} undone, ${failureCount} could not be rolled back.`
+ );
+ } else if (summary.reverted > 0) {
+ toast.success(
+ summary.reverted === 1
+ ? "Reverted 1 downstream action before regenerating."
+ : `Reverted ${summary.reverted} downstream actions before regenerating.`
+ );
+ }
+ if (threadId !== null) {
+ for (const r of summary.results) {
+ if (r.status === "reverted" || r.status === "already_reverted") {
+ markActionRevertedInCache(
+ queryClient,
+ threadId,
+ r.action_id,
+ r.new_action_id ?? null
+ );
+ }
+ }
+ }
+ break;
+ }
+ }
+ });
+
+ batcher.flush();
+
+ // Server-authoritative persistence: ``stream_new_chat``
+ // (regenerate flow) wrote the user row in
+ // ``persist_user_turn`` and finalises the assistant row
+ // in ``finalize_assistant_turn`` from a shielded
+ // ``finally`` block (covers both happy-path and
+ // abort-mid-stream). FE only needs to track the
+ // successful response here.
+ if (contentParts.length > 0) {
+ trackChatResponseReceived(workspaceId, threadId);
+ }
+ } catch (error) {
+ streamBatcher?.dispose();
+ await handleStreamTerminalError({
+ error,
+ flow: "regenerate",
+ threadId,
+ assistantMsgId,
+ accepted: regenerateAccepted,
+ });
+ } finally {
+ setIsRunning(false);
+ abortControllerRef.current = null;
+ void queryClient.invalidateQueries({
+ queryKey: cacheKeys.threads.messages(threadId),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: cacheKeys.threads.detail(threadId),
+ });
+ }
+ },
+ [
+ threadId,
+ workspaceId,
+ messages,
+ disabledTools,
+ localFilesystemEnabled,
+ messageDocumentsMap,
+ setMessageDocumentsMap,
+ queryClient,
+ tokenUsageStore,
+ fetchWithTurnCancellingRetry,
+ handleStreamTerminalError,
+ ]
+ );
+
+ // Handle editing a message - truncates history and regenerates with new query.
+ //
+ // When ``message.sourceId`` is set (the assistant-ui way to say
+ // "this edit replaces an older message"), we pin
+ // ``from_message_id`` so the backend rewinds to the right LangGraph
+ // checkpoint instead of relying on the legacy "last 2 messages"
+ // rewind. We also count downstream reversible actions and prompt the
+ // user to revert / continue / cancel before regenerating.
+ const onEdit = useCallback(
+ async (message: AppendMessage) => {
+ const { userQuery, userImages } = extractUserTurnForNewChatApi(message, []);
+ const queryForApi = userQuery.trim();
+ if (!queryForApi && userImages.length === 0) {
+ toast.error("Cannot edit with empty message");
+ return;
+ }
+
+ const userMessageContent = message.content as unknown as ThreadMessageLike["content"];
+
+ // ``sourceId`` per @assistant-ui/core's ``AppendMessage`` is
+ // "the ID of the message that was edited". Parse the numeric
+ // suffix so we can map it back to a DB row.
+ const sourceId = (message as { sourceId?: string }).sourceId;
+ const fromMessageId =
+ sourceId && /^msg-\d+$/.test(sourceId)
+ ? Number.parseInt(sourceId.replace(/^msg-/, ""), 10)
+ : null;
+
+ if (fromMessageId == null) {
+ // No source id (or non-DB id) — fall back to today's
+ // last-2 behaviour. The user gets the legacy edit flow.
+ await handleRegenerate(queryForApi, {
+ userMessageContent,
+ userImages,
+ sourceUserMessageId: sourceId,
+ });
+ return;
+ }
+
+ // Pre-flight: count reversible downstream actions so we can
+ // auto-skip the dialog for harmless edits.
+ //
+ // "Downstream" means messages AFTER the edited one. The
+ // previous slice ``messages.slice(editedIndex)`` included
+ // the edited message itself in both the total
+ // count and the reversibility scan (any actions on the
+ // edited turn would be double-counted). Slice from
+ // ``editedIndex + 1`` so the dialog text matches reality:
+ // "N downstream messages will be dropped".
+ const editedIndex = messages.findIndex((m) => m.id === `msg-${fromMessageId}`);
+ let downstreamReversibleCount = 0;
+ let downstreamTotalCount = 0;
+ if (editedIndex >= 0) {
+ const downstream = messages.slice(editedIndex + 1);
+ downstreamTotalCount = downstream.length;
+ const seenTurns = new Set();
+ const downstreamTurnIds = new Set();
+ for (const m of downstream) {
+ const meta = (m.metadata ?? {}) as { custom?: { chatTurnId?: string } };
+ const tid = meta.custom?.chatTurnId;
+ if (!tid || seenTurns.has(tid)) continue;
+ seenTurns.add(tid);
+ downstreamTurnIds.add(tid);
+ }
+ // Source of truth: the unified react-query cache. Every
+ // action whose ``chat_turn_id`` belongs to the slice we're
+ // about to drop counts toward the prompt.
+ for (const a of agentActionItems) {
+ if (!a.chat_turn_id || !downstreamTurnIds.has(a.chat_turn_id)) continue;
+ if (
+ a.reversible &&
+ (a.reverted_by_action_id === null || a.reverted_by_action_id === undefined) &&
+ !a.is_revert_action &&
+ (a.error === null || a.error === undefined)
+ ) {
+ downstreamReversibleCount += 1;
+ }
+ }
+ }
+
+ if (downstreamReversibleCount === 0) {
+ // Nothing to revert — submit silently.
+ await handleRegenerate(
+ queryForApi,
+ { userMessageContent, userImages, sourceUserMessageId: sourceId },
+ { fromMessageId, revertActions: false }
+ );
+ return;
+ }
+
+ setEditDialogState({
+ fromMessageId,
+ userQuery: queryForApi,
+ userMessageContent,
+ userImages,
+ downstreamReversibleCount,
+ downstreamTotalCount,
+ });
+ },
+ [handleRegenerate, messages, agentActionItems]
+ );
+
+ const handleApprovalSubmit = useCallback(
+ (interruptId: string, decisions: HitlDecision[]) => {
+ // Stage this card's decisions; only fire the resume once every
+ // pending card in the current turn has submitted, so the
+ // backend slicer sees a single concatenated decisions list
+ // whose total matches the parent state's pending action count.
+ stagedDecisionsByInterruptIdRef.current.set(interruptId, decisions);
+ if (stagedDecisionsByInterruptIdRef.current.size < pendingInterrupts.length) {
+ return;
+ }
+ const ordered: HitlDecision[] = [];
+ for (const pi of pendingInterrupts) {
+ const staged = stagedDecisionsByInterruptIdRef.current.get(pi.interruptId);
+ if (!staged) {
+ // Defensive: a missing entry means the staging map and
+ // the pending list disagreed for one cycle. Bail rather
+ // than dispatch a count-mismatched batch.
+ return;
+ }
+ ordered.push(...staged);
+ }
+ stagedDecisionsByInterruptIdRef.current.clear();
+ window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions: ordered } }));
+ },
+ [pendingInterrupts]
+ );
+
+ const handleEditDialogChoice = useCallback(
+ async (choice: EditMessageDialogChoice) => {
+ const pending = editDialogState;
+ if (!pending) return;
+ setEditDialogState(null);
+ if (choice === "cancel") return;
+ await handleRegenerate(
+ pending.userQuery,
+ {
+ userMessageContent: pending.userMessageContent,
+ userImages: pending.userImages,
+ sourceUserMessageId: `msg-${pending.fromMessageId}`,
+ },
+ {
+ fromMessageId: pending.fromMessageId,
+ revertActions: choice === "revert",
+ }
+ );
+ },
+ [editDialogState, handleRegenerate]
+ );
+
+ // Handle reloading/refreshing the last AI response
+ const onReload = useCallback(async () => {
+ // parentId is the ID of the message to reload from (the user message)
+ // We call regenerate without a query to use the same query
+ await handleRegenerate(null);
+ }, [handleRegenerate]);
+
+ // Surface the thread's deliverables to the layout-level artifacts sidebar.
+ useSyncChatArtifacts(messages);
+
+ // Create external store runtime
+ const runtime = useExternalStoreRuntime({
+ messages,
+ isRunning,
+ onNew,
+ onEdit,
+ onReload,
+ convertMessage,
+ onCancel: cancelRun,
+ });
+
+ const threadLoadError = activeThreadId
+ ? (threadDetailQuery.error ?? threadMessagesQuery.error)
+ : null;
+ const shouldShowThreadLoadError =
+ !!threadLoadError && !!activeThreadId && !currentThread && messages.length === 0;
+ const isThreadMessagesLoading =
+ !!activeThreadId &&
+ threadMessagesQuery.isPending &&
+ messages.length === 0 &&
+ !threadMessagesQuery.error;
+
+ if (shouldShowThreadLoadError) {
+ return (
+
+
Failed to load chat
+
{
+ void Promise.all([threadDetailQuery.refetch(), threadMessagesQuery.refetch()]);
+ }}
+ >
+ Try Again
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {isThreadMessagesLoading ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+ {
+ if (!open) setEditDialogState(null);
+ }}
+ downstreamReversibleCount={editDialogState?.downstreamReversibleCount ?? 0}
+ downstreamTotalCount={editDialogState?.downstreamTotalCount ?? 0}
+ onChoose={handleEditDialogChoice}
+ />
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/new-chat/loading.tsx b/surfsense_web/app/dashboard/[workspace_id]/new-chat/loading.tsx
new file mode 100644
index 000000000..108671662
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/new-chat/loading.tsx
@@ -0,0 +1,62 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+export default function Loading() {
+ return (
+
+
+
+
+ {/* User message */}
+
+
+
+
+ {/* Assistant message */}
+
+
+
+
+
+
+ {/* User message */}
+
+
+
+
+ {/* Assistant message */}
+
+
+
+
+
+
+ {/* User message */}
+
+
+
+
+
+ {/* Input bar */}
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/onboard/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/onboard/page.tsx
new file mode 100644
index 000000000..861ceaa07
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/onboard/page.tsx
@@ -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 (
+
+
+
+
+
Choose a model
+
+ Connect any supported provider, then enable the models you want SurfSense to use.
+
+
+
router.push(`/dashboard/${workspaceId}/new-chat`)}
+ >
+ Start
+
+ }
+ showAddProviderHeader={false}
+ />
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/page.tsx
new file mode 100644
index 000000000..b1d036889
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/page.tsx
@@ -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`);
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/purchase-cancel/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/purchase-cancel/page.tsx
new file mode 100644
index 000000000..77fa27ea0
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/purchase-cancel/page.tsx
@@ -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 (
+
+
+
+
+ Checkout canceled
+ No charge was made and your account is unchanged.
+
+
+ You can return to the pricing options and try again whenever you're ready.
+
+
+
+ Back to Pricing
+
+
+ Back to Dashboard
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/purchase-success/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/purchase-success/page.tsx
new file mode 100644
index 000000000..7dcf22431
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/purchase-success/page.tsx
@@ -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(
+ 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 => {
+ 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 (
+
+
+
+ {state.kind === "loading" || state.kind === "pending" ? (
+
+ ) : state.kind === "completed" ? (
+
+ ) : (
+
+ )}
+
+ {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"}
+
+
+ {state.kind === "loading" && "We're verifying your payment with Stripe."}
+ {state.kind === "pending" &&
+ "Your bank is taking a moment to confirm. This usually takes 5–30 seconds."}
+ {state.kind === "still_pending" &&
+ "Your payment is still being processed by your bank. We'll apply your purchase as soon as it clears — usually within a few minutes. You can safely close this page."}
+ {state.kind === "completed" &&
+ `Added ${formatCredit(state.data.credit_micros_granted ?? 0)} of credit to your account.`}
+ {state.kind === "failed" &&
+ "Stripe reported the checkout as failed or expired. Your card was not charged."}
+ {state.kind === "error" &&
+ "Don't worry — if your card was charged, your purchase will still apply within a minute or two."}
+ {state.kind === "no_session" && "Your purchase is being applied to your account."}
+
+
+
+ {state.kind === "completed" && (
+
+ New credit balance: {formatCredit(state.data.credit_micros_balance ?? 0)}
+
+ )}
+ {state.kind === "error" && (
+ {state.message}
+ )}
+
+
+
+ Back to Dashboard
+
+
+ Buy credits
+
+
+
+
+ );
+}
+
+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);
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/general/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/general/page.tsx
new file mode 100644
index 000000000..828f651d1
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/general/page.tsx
@@ -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 ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/layout-shell.tsx b/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/layout-shell.tsx
new file mode 100644
index 000000000..45e1248c6
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/layout-shell.tsx
@@ -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) => {
+ 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: ,
+ },
+ {
+ value: "models" as const,
+ label: t("nav_models"),
+ icon: ,
+ },
+ {
+ value: "team-roles" as const,
+ label: t("nav_team_roles"),
+ icon: ,
+ },
+ {
+ value: "prompts" as const,
+ label: t("nav_system_instructions"),
+ icon: ,
+ },
+ {
+ value: "public-links" as const,
+ label: t("nav_public_links"),
+ icon: ,
+ },
+ ],
+ [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 (
+
+
+
{t("title")}
+
+ {navItems.map((item) => (
+
+ {item.icon}
+ {item.label}
+
+ ))}
+
+
+
+ {navItems.map((item) => (
+
+ {item.icon}
+ {item.label}
+
+ ))}
+
+
+
+
+
+
+
{selectedLabel}
+
+
+
{children}
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/layout.tsx b/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/layout.tsx
new file mode 100644
index 000000000..906025090
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/layout.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/models/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/models/page.tsx
new file mode 100644
index 000000000..49d5d4460
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/models/page.tsx
@@ -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 ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/page.tsx
new file mode 100644
index 000000000..692ba563c
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/page.tsx
@@ -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`);
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/prompts/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/prompts/page.tsx
new file mode 100644
index 000000000..fc24cffde
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/prompts/page.tsx
@@ -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 ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/public-links/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/public-links/page.tsx
new file mode 100644
index 000000000..4b9d4260a
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/public-links/page.tsx
@@ -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 ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/team-roles/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/team-roles/page.tsx
new file mode 100644
index 000000000..cb8ba0dc1
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/search-space-settings/team-roles/page.tsx
@@ -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 ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/team/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/team/page.tsx
new file mode 100644
index 000000000..4f986b287
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/team/page.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/team/team-content.tsx b/surfsense_web/app/dashboard/[workspace_id]/team/team-content.tsx
new file mode 100644
index 000000000..afe640543
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/team/team-content.tsx
@@ -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 => {
+ 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 => {
+ 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 (
+
+
+
+
Members
+
+
+
+
+ Invite members
+
+
+
+ Active invites
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name
+
+
+
+
+
+ Last logged in
+
+
+
+
+
+ Role
+
+
+
+
+
+ {SKELETON_KEYS.slice(0, 2).map((id) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
Members
+
+ {members.length} {members.length === 1 ? "member" : "members"}
+
+
+ {canInvite && (
+
+ {rolesLoading ? (
+
+
+ Invite members
+
+ ) : (
+
+ )}
+ {invitesLoading ? (
+
+
+ Active invites
+
+
+
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+
+ Name
+
+
+
+
+
+ Last logged in
+
+
+
+
+
+ Role
+
+
+
+
+
+ {owners.map((member) => (
+
+ ))}
+ {paginatedMembers.map((member) => (
+
+ ))}
+ {members.length === 0 && (
+
+
+
+
+
+ )}
+
+
+
+
+ {totalItems > PAGE_SIZE && (
+
+
+ {displayStart}-{displayEnd} of {totalItems}
+
+
+ setPageIndex(0)}
+ disabled={!canPrev}
+ aria-label="Go to first page"
+ >
+
+
+ setPageIndex((i) => Math.max(0, i - 1))}
+ disabled={!canPrev}
+ aria-label="Go to previous page"
+ >
+
+
+ setPageIndex((i) => (canNext ? i + 1 : i))}
+ disabled={!canNext}
+ aria-label="Go to next page"
+ >
+
+
+ setPageIndex(lastPage)}
+ disabled={!canNext}
+ aria-label="Go to last page"
+ >
+
+
+
+
+ )}
+
+ );
+}
+
+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;
+ onRemoveMember: (membershipId: number) => Promise;
+}) {
+ 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 (
+
+
+
+
+ {member.user_avatar_url && (
+
+ )}
+
+ {initials}
+
+
+
+
{displayName}
+ {member.user_display_name && member.user_email && (
+
+ {member.user_email}
+
+ )}
+
+
+
+
+
+ {member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"}
+
+
+
+ {showActions ? (
+
+
+
+ {roleName}
+
+
+
+ e.preventDefault()}
+ className="min-w-[120px]"
+ >
+ {canManageRoles &&
+ roles
+ .filter((r) => r.name !== "Owner")
+ .map((role) => (
+ onUpdateRole(member.id, role.id)}
+ >
+ Make {role.name}
+
+ ))}
+ {canRemove && (
+
+
+ e.preventDefault()}
+ className="text-destructive focus:text-destructive"
+ >
+ Remove
+
+
+
+
+ Remove member?
+
+ This will remove {member.user_email} {" "}
+ from this search space. They will lose access to all resources.
+
+
+
+ Cancel
+ onRemoveMember(member.id)}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ Remove
+
+
+
+
+ )}
+
+
+ router.push(`/dashboard/${workspaceId}/search-space-settings/team-roles`)
+ }
+ >
+ Manage Roles
+
+
+
+ ) : (
+ {roleName}
+ )}
+
+
+ );
+}
+
+function CreateInviteDialog({
+ roles,
+ onCreateInvite,
+ workspaceId,
+}: {
+ roles: Role[];
+ onCreateInvite: (data: CreateInviteRequest["data"]) => Promise;
+ workspaceId: number;
+}) {
+ const [open, setOpen] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [name, setName] = useState("");
+ const [roleId, setRoleId] = useState("");
+ const [maxUses, setMaxUses] = useState("");
+ const [expiresAt, setExpiresAt] = useState(undefined);
+ const [createdInvite, setCreatedInvite] = useState(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 (
+ (v ? setOpen(true) : handleClose())}>
+
+
+
+ Invite members
+
+
+ e.preventDefault()}
+ >
+ {createdInvite ? (
+ <>
+
+
+
+ Invite Created!
+
+
+ Share this link to invite people to your search space.
+
+
+
+
+
+ {window.location.origin}/invite/{createdInvite.invite_code}
+
+
+ {copiedLink ? : }
+
+
+
+
+ {createdInvite.role?.name || "Default role"}
+
+ {createdInvite.max_uses && (
+
+
+ Max {createdInvite.max_uses} uses
+
+ )}
+ {createdInvite.expires_at && (
+
+
+ Expires {new Date(createdInvite.expires_at).toLocaleDateString()}
+
+ )}
+
+
+
+ Done
+
+ >
+ ) : (
+ <>
+
+ Invite Members
+
+ Create a link to invite people to this search space.
+
+
+
+
+ Name (optional)
+ setName(e.target.value)}
+ />
+
+
+ Role
+
+
+
+
+
+ {assignableRoles.map((role) => (
+
+
+ {role.name}
+ {role.is_default && (
+ (default)
+ )}
+
+
+ ))}
+
+
+
+
+
+ Max uses (optional)
+ setMaxUses(e.target.value)}
+ />
+
+
+
Expires on (optional)
+
+
+
+
+ {expiresAt ? expiresAt.toLocaleDateString() : "Never"}
+
+
+
+ date < new Date()}
+ initialFocus
+ />
+
+
+
+
+
+
+
+ Cancel
+
+
+ {creating ? (
+ <>
+
+ Creating
+ >
+ ) : (
+ "Create Invite"
+ )}
+
+
+ >
+ )}
+
+
+ );
+}
+
+function AllInvitesDialog({
+ invites,
+ onRevokeInvite,
+}: {
+ invites: Invite[];
+ onRevokeInvite: (inviteId: number) => Promise;
+}) {
+ const [copiedId, setCopiedId] = useState(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 (
+
+
+
+
+ Active invites
+
+ {invites.length}
+
+
+
+
+
+ Active Invite Links
+
+ {invites.length} active {invites.length === 1 ? "invite" : "invites"}. Copy a link or
+ revoke access.
+
+
+
+ {invites.map((invite) => (
+
+
+
+
{invite.name || "Unnamed invite"}
+
+ {invite.role?.name && (
+ {invite.role.name}
+ )}
+ {invite.max_uses != null && (
+
+
+ {invite.uses_count}/{invite.max_uses}
+
+ )}
+ {invite.expires_at && (
+
+
+ {new Date(invite.expires_at).toLocaleDateString()}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ Revoke invite?
+
+ This will permanently delete this invite link. Anyone with this link will no
+ longer be able to join.
+
+
+
+ Cancel
+ onRevokeInvite(invite.id)}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ Revoke
+
+
+
+
+
+
+
+
+ {typeof window !== "undefined"
+ ? `${window.location.origin}/invite/${invite.invite_code}`
+ : `/invite/${invite.invite_code}`}
+
+
+
copyLink(invite)}
+ >
+ {copiedId === invite.id ? (
+
+ ) : (
+
+ )}
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/agent-permissions/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/agent-permissions/page.tsx
new file mode 100644
index 000000000..2b925bd29
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/agent-permissions/page.tsx
@@ -0,0 +1,5 @@
+import { AgentPermissionsContent } from "../components/AgentPermissionsContent";
+
+export default function Page() {
+ return ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/agent-status/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/agent-status/page.tsx
new file mode 100644
index 000000000..dc5c61d2a
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/agent-status/page.tsx
@@ -0,0 +1,5 @@
+import { AgentStatusContent } from "../components/AgentStatusContent";
+
+export default function Page() {
+ return ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/api-key/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/api-key/page.tsx
new file mode 100644
index 000000000..e59e50042
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/api-key/page.tsx
@@ -0,0 +1,5 @@
+import { ApiKeyContent } from "../components/ApiKeyContent";
+
+export default function Page() {
+ return ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/community-prompts/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/community-prompts/page.tsx
new file mode 100644
index 000000000..e962cbed2
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/community-prompts/page.tsx
@@ -0,0 +1,5 @@
+import { CommunityPromptsContent } from "../components/CommunityPromptsContent";
+
+export default function Page() {
+ return ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/AgentPermissionsContent.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/AgentPermissionsContent.tsx
new file mode 100644
index 000000000..6cddb2b9e
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/AgentPermissionsContent.tsx
@@ -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 = {
+ allow: "Always run without prompting",
+ deny: "Block silently",
+ ask: "Pause and ask for approval",
+};
+
+const ACTION_BADGE: Record = {
+ 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 (
+
+ Thread #{rule.thread_id}
+
+ );
+ }
+ if (rule.user_id !== null) {
+ return (
+
+ User-specific
+
+ );
+ }
+ return (
+
+ Search space
+
+ );
+}
+
+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(EMPTY_FORM);
+ const [deleteTarget, setDeleteTarget] = useState(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 (
+
+
+ Permission middleware is disabled
+
+
+ Flip{" "}
+
+ SURFSENSE_ENABLE_PERMISSION
+ {" "}
+ on the backend to manage allow/deny/ask rules from this panel.
+
+
+
+ );
+ }
+
+ if (!workspaceId) {
+ return (
+ Open a search space to manage agent rules.
+ );
+ }
+
+ return (
+
+
+
+
+ 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.
+
+
+
{
+ setShowForm(true);
+ setFormData(EMPTY_FORM);
+ }}
+ className="shrink-0 gap-1.5"
+ >
+ New rule
+
+
+
+
{
+ setShowForm(open);
+ if (!open) setFormData(EMPTY_FORM);
+ }}
+ >
+
+
+ New permission rule
+
+ Tell the agent whether matching tool calls should be allowed, denied, or paused for
+ approval.
+
+
+
+
+
+
+
Permission
+
setFormData((p) => ({ ...p, permission: e.target.value }))}
+ />
+
+ Match a tool capability. Use * for wildcards.
+
+
+
+
+
Argument pattern
+
setFormData((p) => ({ ...p, pattern: e.target.value }))}
+ />
+
+ Wildcard against the canonical argument (e.g. prod-*).
+
+
+
+
+
+
Action
+
+ setFormData((p) => ({ ...p, action: value as AgentPermissionAction }))
+ }
+ >
+
+
+
+
+ Allow (run without asking)
+ Ask (pause for approval)
+ Deny (block silently)
+
+
+
+ {ACTION_DESCRIPTIONS[formData.action]}
+
+
+
+
+
+ {
+ setShowForm(false);
+ setFormData(EMPTY_FORM);
+ }}
+ disabled={createMutation.isPending}
+ className="text-sm h-9"
+ >
+ Cancel
+
+
+ Create
+ {createMutation.isPending && }
+
+
+
+
+
+ {isLoading && (
+
+ {["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {isError && (
+
+
+ Failed to load rules
+
+ {error instanceof Error ? error.message : "Unknown error."}
+
+
+ )}
+
+ {!isLoading && !isError && sortedRules.length === 0 && !showForm && (
+
+
+
No rules yet
+
+ Without rules the agent uses the deployment default for every tool.
+
+
+ )}
+
+ {!isLoading && !isError && sortedRules.length > 0 && (
+
+ {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 (
+
+
+
+
+
+ {rule.permission}
+
+ {rule.pattern !== "*" && (
+
+ → {rule.pattern}
+
+ )}
+
+
+
+ Created {formatRelativeDate(rule.created_at)}
+
+
+
+
+
+ updateMutation.mutate({
+ ruleId: rule.id,
+ action: value as AgentPermissionAction,
+ })
+ }
+ disabled={isUpdating || isDeleting}
+ >
+
+
+ {badge.label}
+
+
+
+ Allow
+ Ask
+ Deny
+
+
+
+ setDeleteTarget(rule.id)}
+ disabled={isUpdating || isDeleting}
+ aria-label="Delete rule"
+ >
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
!open && setDeleteTarget(null)}
+ >
+
+
+ Delete this rule?
+
+ The agent will fall back to deployment defaults for matching tool calls.
+
+
+
+ Cancel
+ {
+ e.preventDefault();
+ handleConfirmDelete();
+ }}
+ disabled={deleteMutation.isPending}
+ className="relative min-w-[88px]"
+ >
+ Delete
+ {deleteMutation.isPending && }
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/AgentStatusContent.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/AgentStatusContent.tsx
new file mode 100644
index 000000000..bc31dffed
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/AgentStatusContent.tsx
@@ -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 (
+
+
+
+ {def.label}
+
+ {def.envVar}
+
+
+
{def.description}
+
+
+ {value ? : }
+ {value ? "On" : "Off"}
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+ );
+ }
+
+ if (isError || !flags) {
+ return (
+
+
+ Failed to load agent status
+
+ {error instanceof Error ? error.message : "Unknown error."}
+
+
+ );
+ }
+
+ const masterOff = flags.disable_new_agent_stack;
+
+ return (
+
+ {masterOff ? (
+
+
+ Master kill-switch is on
+
+
+ Showing that{" "}
+
+ SURFSENSE_DISABLE_NEW_AGENT_STACK=true
+
+ , which forces every new middleware off, regardless of the individual flags below.
+ Restart the backend after changing it.
+
+
+
+ ) : (
+
+
+
+ Agent stack
+
+ {enabledCount} on
+
+
+
+
+ Showing a read-only mirror of the backend's AgentFeatureFlags. Flip an
+ env var and restart the backend to change a value.
+
+
+
+ )}
+
+ {FLAG_GROUPS.map((group, groupIdx) => {
+ const allOff = group.flags.every((f) => !flags[f.key]);
+ return (
+
+ {groupIdx > 0 &&
}
+
+
+
+
{group.title}
+
{group.subtitle}
+
+ {allOff && (
+
+ all off
+
+ )}
+
+
+
+ {group.flags.map((def, flagIdx) => (
+
+ {flagIdx > 0 && }
+
+
+ ))}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/ApiKeyContent.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/ApiKeyContent.tsx
new file mode 100644
index 000000000..8f0af894f
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/ApiKeyContent.tsx
@@ -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 (
+
+
+
+
+ API keys let extensions, Obsidian, and other apps connect to SurfSense.
+
+
+
+
+
+
API keys
+
+ Expired API keys stay listed until you delete them.
+
+
+
setCreateOpen(true)}>
+ Create API key
+
+
+
+ {isLoading ? (
+
+ {["skeleton-a", "skeleton-b"].map((key) => (
+
+
+
+
+
+
+
+ ))}
+
+ ) : sortedTokens.length > 0 ? (
+
+ {sortedTokens.map((token) => {
+ const expiresAt = token.expires_at ? new Date(token.expires_at) : null;
+ const isExpired = expiresAt ? expiresAt.getTime() <= Date.now() : false;
+ return (
+
+
+
+
+
+
+ {token.label}
+
+ {isExpired ? (
+
+ Expired
+
+ ) : null}
+
+
+ {token.prefix}...
+
+
+ Expires: {expiresAt ? expiresAt.toLocaleDateString() : "Never"} · Last used:{" "}
+ {token.last_used_at
+ ? new Date(token.last_used_at).toLocaleString()
+ : "Never"}
+
+
+
+ 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"
+ >
+
+
+
+
+ );
+ })}
+
+ ) : (
+
No API keys yet.
+ )}
+
+
+
+
+ Create API key
+
+ Name this API key so you can recognize where it is used later.
+
+
+
+
+ setCreateOpen(false)}
+ disabled={isMutating}
+ className="text-sm h-9"
+ >
+ Cancel
+
+
+ Create API key
+ {isMutating && }
+
+
+
+
+
+
!open && setCreatedToken(null)}>
+
+
+ Copy your API key now
+
+ This API key is shown only once. Store it somewhere secure before closing this dialog.
+
+
+
+
+ {createdToken?.token}
+
+
+ {copiedToken ? : }
+
+
+
+ setCreatedToken(null)}>Done
+
+
+
+
+
!open && setDeleteTarget(null)}
+ >
+
+
+ Delete API key?
+
+ {deleteTarget?.label} will be
+ permanently removed. This cannot be undone.
+
+
+
+ Cancel
+ {
+ event.preventDefault();
+ void handleConfirmDelete();
+ }}
+ >
+ {isMutating ? (
+
+
+ Deleting...
+
+ ) : (
+ "Delete"
+ )}
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/CommunityPromptsContent.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/CommunityPromptsContent.tsx
new file mode 100644
index 000000000..f4454f343
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/CommunityPromptsContent.tsx
@@ -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>(new Set());
+ const [expandedId, setExpandedId] = useState(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 (
+
+
+ Prompts shared by other users. Add any to your collection with one click.
+
+
+ {isLoading && (
+
+ {["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {isError && (
+
+
+ Failed to load community prompts
+ Please try refreshing the page.
+
+ )}
+
+ {!isLoading && !isError && list.length === 0 && (
+
+
+
No community prompts yet
+
+ Share your own prompts from the My Prompts tab
+
+
+ )}
+
+ {!isLoading && !isError && list.length > 0 && (
+
+ {list.map((prompt) => (
+
+
+
+
+ {prompt.name}
+
+ {prompt.mode}
+
+ {prompt.author_name && (
+
+ by {prompt.author_name}
+
+ )}
+
+
+ {prompt.prompt}
+
+ {prompt.prompt.length > 100 && (
+
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"}
+
+ )}
+
+ handleCopy(prompt.id)}
+ >
+ {copyingIds.has(prompt.id) ? (
+
+ ) : (
+
+ )}
+ Add to mine
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/DesktopContent.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/DesktopContent.tsx
new file mode 100644
index 000000000..97debca9d
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/DesktopContent.tsx
@@ -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([]);
+ const [activeSpaceId, setActiveSpaceId] = useState(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 (
+
+
+ App preferences are only available in the SurfSense desktop app.
+
+
+ );
+ }
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ 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 (
+
+
+
+
Default Search Space
+
+ Choose which search space General Assist, Screenshot Assist, and Quick Assist use by
+ default.
+
+
+
+ {searchSpaces.length > 0 ? (
+
+
+
+
+
+ {searchSpaces.map((space) => (
+
+ {space.name}
+
+ ))}
+
+
+ ) : (
+
+ No search spaces found. Create one first.
+
+ )}
+
+
+
+
+
+
+
+
+ Launch on Startup
+
+
+ Automatically start SurfSense when you sign in to your computer so global shortcuts and
+ folder sync are always available.
+
+
+
+
+
+
+ Open SurfSense at login
+
+
+ {autoLaunchSupported
+ ? "Adds SurfSense to your system's login items."
+ : "Only available in the packaged desktop app."}
+
+
+
+
+
+
+
+ Start minimized to tray
+
+
+ Skip the main window on boot. SurfSense lives in the system tray until you need it.
+
+
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/HotkeysContent.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/HotkeysContent.tsx
new file mode 100644
index 000000000..9916f6cda
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/HotkeysContent.tsx
@@ -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(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 (
+
+
+
+ {!isDefault && (
+
+
+
+ )}
+ 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 ? (
+ Press hotkeys
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+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 (
+
+
+ Hotkeys are only available in the SurfSense desktop app.
+
+
+ );
+ }
+
+ 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 ? (
+
+
+ {HOTKEY_ROWS.map((row, index) => (
+
+ updateShortcut(row.key, accel)}
+ onReset={() => resetShortcut(row.key)}
+ />
+ {index < HOTKEY_ROWS.length - 1 ? : null}
+
+ ))}
+
+
+ ) : (
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/MessagingChannelsContent.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/MessagingChannelsContent.tsx
new file mode 100644
index 000000000..3c1d3aef4
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/MessagingChannelsContent.tsx
@@ -0,0 +1,602 @@
+"use client";
+
+import { AlertTriangle, RefreshCw, ShieldAlert } from "lucide-react";
+import { useParams } from "next/navigation";
+import { QRCodeSVG } from "qrcode.react";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { SearchSpace } from "@/contracts/types/search-space.types";
+import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
+import { authenticatedFetch } from "@/lib/auth-fetch";
+import { buildBackendUrl } from "@/lib/env-config";
+import { cn } from "@/lib/utils";
+
+type GatewayConnection = {
+ id: number;
+ account_id?: number | null;
+ route_type?: "account" | "binding";
+ platform: string;
+ mode?: string;
+ state: string;
+ workspace_id: number;
+ display_name?: string | null;
+ external_username?: string | null;
+ workspace_name?: string | null;
+ health_status: string;
+ suspended_reason?: string | null;
+};
+
+type GatewayConfig = {
+ enabled: boolean;
+ telegram_enabled: boolean;
+ whatsapp_intake_mode: "disabled" | "cloud" | "baileys";
+ slack_enabled: boolean;
+ discord_enabled: boolean;
+};
+
+type GatewayConfigState = GatewayConfig | null;
+
+const DISABLED_GATEWAY_CONFIG: GatewayConfig = {
+ enabled: false,
+ telegram_enabled: false,
+ whatsapp_intake_mode: "disabled",
+ slack_enabled: false,
+ discord_enabled: false,
+};
+
+type Pairing = {
+ binding_id: number;
+ code: string;
+ deep_link: string;
+ expires_at: string;
+};
+
+type PairingPlatform = "telegram" | "whatsapp";
+type GatewayPlatform = PairingPlatform | "slack" | "discord";
+
+type BaileysHealth = {
+ status: string;
+ hasQr: boolean;
+ qr?: string | null;
+ queueDepth?: number;
+ user?: unknown;
+};
+
+export function MessagingChannelsContent() {
+ const params = useParams<{ workspace_id: string }>();
+ const workspaceId = Number(params.workspace_id);
+ const [gatewayConfig, setGatewayConfig] = useState(null);
+ const [connections, setConnections] = useState([]);
+ const [searchSpaces, setSearchSpaces] = useState([]);
+ const [pairing, setPairing] = useState(null);
+ const [pairingPlatform, setPairingPlatform] = useState(null);
+ const [baileysHealth, setBaileysHealth] = useState(null);
+ const [refreshingPlatform, setRefreshingPlatform] = useState(null);
+ const isGatewayConfigLoading = gatewayConfig === null;
+ const telegramGatewayEnabled = gatewayConfig?.telegram_enabled ?? false;
+ const whatsappMode = gatewayConfig?.whatsapp_intake_mode ?? "disabled";
+ const slackGatewayEnabled = gatewayConfig?.slack_enabled ?? false;
+ const discordGatewayEnabled = gatewayConfig?.discord_enabled ?? false;
+ const gatewayDisabled = gatewayConfig?.enabled === false;
+
+ const fetchConnections = useCallback(async (platform?: GatewayPlatform) => {
+ const res = await authenticatedFetch(
+ buildBackendUrl("/api/v1/gateway/connections", platform ? { platform } : undefined)
+ );
+ if (!res.ok) return [];
+ const data = await res.json();
+ return Array.isArray(data) ? (data as GatewayConnection[]) : [];
+ }, []);
+
+ const fetchGatewayConfig = useCallback(async (): Promise => {
+ const res = await authenticatedFetch(buildBackendUrl("/api/v1/gateway/config"));
+ if (!res.ok) return DISABLED_GATEWAY_CONFIG;
+ const data = (await res.json()) as Partial;
+ return {
+ ...DISABLED_GATEWAY_CONFIG,
+ ...data,
+ enabled: data.enabled ?? true,
+ };
+ }, []);
+
+ const refresh = useCallback(async () => {
+ const [nextConnections, spaces, nextGatewayConfig] = await Promise.all([
+ fetchConnections(),
+ searchSpacesApiService.getSearchSpaces(),
+ fetchGatewayConfig(),
+ ]);
+ setConnections(nextConnections);
+ setSearchSpaces(spaces);
+ setGatewayConfig(nextGatewayConfig);
+ }, [fetchConnections, fetchGatewayConfig]);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ const refreshPlatform = useCallback(
+ async (platform: GatewayPlatform) => {
+ setRefreshingPlatform(platform);
+ try {
+ const nextConnections = await fetchConnections(platform);
+ setConnections((current) => [
+ ...current.filter((connection) => connection.platform !== platform),
+ ...nextConnections,
+ ]);
+ } finally {
+ setRefreshingPlatform(null);
+ }
+ },
+ [fetchConnections]
+ );
+
+ const refreshBaileysHealth = useCallback(async () => {
+ if (whatsappMode !== "baileys") return;
+ const res = await authenticatedFetch(
+ buildBackendUrl("/api/v1/gateway/whatsapp/baileys/health")
+ );
+ if (!res.ok) return;
+ const data = (await res.json()) as BaileysHealth;
+ setBaileysHealth(data);
+ }, [whatsappMode]);
+
+ useEffect(() => {
+ void refreshBaileysHealth();
+ }, [refreshBaileysHealth]);
+
+ async function startPairing(platform: PairingPlatform) {
+ const res = await authenticatedFetch(buildBackendUrl("/api/v1/gateway/bindings/start"), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ platform, workspace_id: workspaceId }),
+ });
+ setPairing(await res.json());
+ setPairingPlatform(platform);
+ await refreshPlatform(platform);
+ }
+
+ async function installSlackGateway() {
+ const res = await authenticatedFetch(
+ buildBackendUrl("/api/v1/gateway/slack/install", { workspace_id: workspaceId })
+ );
+ if (!res.ok) return;
+ const data = (await res.json()) as { auth_url?: string };
+ if (data.auth_url) {
+ window.location.href = data.auth_url;
+ }
+ }
+
+ async function installDiscordGateway() {
+ const res = await authenticatedFetch(
+ buildBackendUrl("/api/v1/gateway/discord/install", { workspace_id: workspaceId })
+ );
+ if (!res.ok) return;
+ const data = (await res.json()) as { auth_url?: string };
+ if (data.auth_url) {
+ window.location.href = data.auth_url;
+ }
+ }
+
+ async function refreshBaileys() {
+ await refreshBaileysHealth();
+ await refreshPlatform("whatsapp");
+ }
+
+ const connectionKey = (connection: GatewayConnection) =>
+ connection.route_type === "account" && connection.account_id
+ ? `account:${connection.account_id}`
+ : `binding:${connection.id}`;
+
+ async function revoke(connection: GatewayConnection) {
+ const url =
+ connection.route_type === "account" && connection.account_id
+ ? buildBackendUrl(`/api/v1/gateway/accounts/${connection.account_id}`)
+ : buildBackendUrl(`/api/v1/gateway/bindings/${connection.id}`);
+ await authenticatedFetch(url, {
+ method: "DELETE",
+ });
+ await refreshPlatform(connection.platform as GatewayPlatform);
+ }
+
+ async function updateConnectionSearchSpace(
+ connection: GatewayConnection,
+ nextWorkspaceId: string
+ ) {
+ const previousConnections = connections;
+ const parsedWorkspaceId = Number(nextWorkspaceId);
+ const targetKey = connectionKey(connection);
+ setConnections((current) =>
+ current.map((connection) =>
+ connectionKey(connection) === targetKey
+ ? { ...connection, workspace_id: parsedWorkspaceId }
+ : connection
+ )
+ );
+ const url =
+ connection.route_type === "account" && connection.account_id
+ ? buildBackendUrl(`/api/v1/gateway/accounts/${connection.account_id}/workspace`)
+ : buildBackendUrl(`/api/v1/gateway/bindings/${connection.id}/workspace`);
+ const res = await authenticatedFetch(url, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ workspace_id: parsedWorkspaceId }),
+ });
+ if (!res.ok) {
+ setConnections(previousConnections);
+ toast.error("Failed to update messaging route");
+ return;
+ }
+ toast.success("Messaging route updated");
+ await refreshPlatform(connection.platform as GatewayPlatform);
+ }
+
+ async function resume(connection: GatewayConnection) {
+ await authenticatedFetch(buildBackendUrl(`/api/v1/gateway/bindings/${connection.id}/resume`), {
+ method: "POST",
+ });
+ await refreshPlatform(connection.platform as GatewayPlatform);
+ }
+
+ const isConnectionInActiveMode = (connection: GatewayConnection) => {
+ if (connection.platform !== "whatsapp") return true;
+ if (whatsappMode === "baileys") return connection.mode === "self_host_byo";
+ if (whatsappMode === "cloud") return connection.mode !== "self_host_byo";
+ return false;
+ };
+ const baileysQr = baileysHealth?.qr || null;
+ const hasTelegramConnection = connections.some(
+ (connection) => connection.platform === "telegram"
+ );
+ const hasWhatsAppConnection = connections.some(
+ (connection) => connection.platform === "whatsapp" && isConnectionInActiveMode(connection)
+ );
+ const hasEnabledGateway =
+ telegramGatewayEnabled ||
+ whatsappMode !== "disabled" ||
+ slackGatewayEnabled ||
+ discordGatewayEnabled;
+ const isRefreshing = (platform: GatewayPlatform) => refreshingPlatform === platform;
+ const refreshButtonClassName = "gap-2";
+ const refreshIconClassName = (platform: GatewayPlatform) =>
+ cn("mr-2 h-4 w-4", isRefreshing(platform) && "animate-spin");
+ const platformLabel = (platform: string) => {
+ switch (platform) {
+ case "discord":
+ return "Discord";
+ case "slack":
+ return "Slack";
+ case "telegram":
+ return "Telegram";
+ case "whatsapp":
+ return "WhatsApp";
+ default:
+ return platform;
+ }
+ };
+ const connectionTitle = (connection: GatewayConnection) =>
+ connection.platform === "whatsapp" && connection.mode === "self_host_byo"
+ ? "WhatsApp Bridge"
+ : connection.workspace_name ||
+ connection.display_name ||
+ connection.external_username ||
+ `${platformLabel(connection.platform)} connection`;
+ const renderConnectionRows = (platform: GatewayConnection["platform"], emptyText: string) => {
+ const platformConnections = connections.filter(
+ (connection) => connection.platform === platform && isConnectionInActiveMode(connection)
+ );
+
+ if (platformConnections.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
Connected accounts
+ {platformConnections.map((connection, index) => (
+
+ {index > 0 ?
: null}
+
+
+
{connectionTitle(connection)}
+ {connection.suspended_reason ? (
+
+
+ {connection.suspended_reason}
+
+ ) : null}
+
+
+ updateConnectionSearchSpace(connection, value)}
+ disabled={searchSpaces.length === 0}
+ >
+
+
+
+
+ {searchSpaces.map((space) => (
+
+ {space.name}
+
+ ))}
+
+
+ {connection.state === "suspended" ? (
+ resume(connection)}
+ >
+ Resume
+
+ ) : null}
+ revoke(connection)}
+ >
+ Disconnect
+
+
+
+
+ ))}
+
+ );
+ };
+ const renderPairingPanel = (platform: PairingPlatform) => {
+ if (!pairing || pairingPlatform !== platform) return null;
+
+ return (
+
+ );
+ };
+ const renderGatewaySkeletons = () => (
+ <>
+ {[0, 1].map((index) => (
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+ >
+ );
+
+ return (
+
+ {isGatewayConfigLoading ? renderGatewaySkeletons() : null}
+
+ {!isGatewayConfigLoading && gatewayDisabled ? (
+
+
+ Messaging Channels coming soon
+
+
+ Soon you'll be able to connect WhatsApp, Telegram, Slack, and Discord to your
+ SurfSense agent so you can ask questions, route messages to search spaces, and get
+ answers from your knowledge base without leaving your chat app.
+
+
+
+ ) : null}
+
+ {!isGatewayConfigLoading && !gatewayDisabled && !hasEnabledGateway ? (
+
+
+ No messaging gateways enabled
+
+
+ ) : null}
+
+ {!gatewayDisabled && telegramGatewayEnabled ? (
+
+
+
+ Telegram
+
+
+ Connect Telegram to chat with SurfSense.
+
+
+
+
+ {hasTelegramConnection ? null : (
+ startPairing("telegram")}>
+ Pair Telegram Chat
+
+ )}
+ refreshPlatform("telegram")}
+ disabled={isRefreshing("telegram")}
+ >
+
+ Refresh
+
+
+
+ {hasTelegramConnection ? null : renderPairingPanel("telegram")}
+
+ {renderConnectionRows("telegram", "No Telegram chats connected yet.")}
+
+
+ ) : null}
+
+ {!gatewayDisabled && slackGatewayEnabled ? (
+
+
+
+ Slack
+
+
+ Enable the SurfSense Slack bot so teammates can mention it in Slack.
+
+
+
+
+
+ Add Slack Workspace
+
+ refreshPlatform("slack")}
+ disabled={isRefreshing("slack")}
+ >
+
+ Refresh
+
+
+
+ {renderConnectionRows("slack", "No Slack workspaces connected yet.")}
+
+
+ ) : null}
+
+ {!gatewayDisabled && discordGatewayEnabled ? (
+
+
+
+ Discord
+
+
+ Enable the SurfSense Discord bot so teammates can mention it in Discord.
+
+
+
+
+
+ Add Discord Server
+
+ refreshPlatform("discord")}
+ disabled={isRefreshing("discord")}
+ >
+
+ Refresh
+
+
+
+ {renderConnectionRows("discord", "No Discord servers connected yet.")}
+
+
+ ) : null}
+
+ {!gatewayDisabled && whatsappMode !== "disabled" ? (
+
+
+
+ WhatsApp
+
+
+ {whatsappMode === "baileys"
+ ? 'Use "Message Yourself". Other chats are ignored.'
+ : "Connect WhatsApp to chat with Surfsense."}
+
+
+
+ {whatsappMode === "cloud" ? (
+
+
+ {hasWhatsAppConnection ? null : (
+ startPairing("whatsapp")}>
+ Pair WhatsApp
+
+ )}
+ refreshPlatform("whatsapp")}
+ disabled={isRefreshing("whatsapp")}
+ >
+
+ Refresh
+
+
+ {hasWhatsAppConnection ? null : renderPairingPanel("whatsapp")}
+
+ ) : null}
+ {whatsappMode === "baileys" ? (
+
+
+
+ Refresh
+
+ {baileysQr ? (
+
+
WhatsApp QR pairing
+
+ Scan this QR from WhatsApp > Linked Devices > Link a Device.
+
+
+
+
+
+ ) : null}
+ {baileysHealth ? (
+
+ Bridge status: {baileysHealth.status}
+ {typeof baileysHealth.queueDepth === "number"
+ ? `, queue: ${baileysHealth.queueDepth}`
+ : ""}
+
+ ) : null}
+
+ ) : null}
+
+ {renderConnectionRows("whatsapp", "No WhatsApp chats connected yet.")}
+
+
+ ) : null}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/ProfileContent.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/ProfileContent.tsx
new file mode 100644
index 000000000..89bc362eb
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/ProfileContent.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import Image from "next/image";
+import { useTranslations } from "next-intl";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms";
+import { currentUserAtom } from "@/atoms/user/user-query.atoms";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Spinner } from "@/components/ui/spinner";
+import { getUserAvatarColor, getUserInitials } from "@/lib/user-avatar";
+
+function AvatarDisplay({
+ url,
+ fallback,
+ bgColor,
+}: {
+ url?: string;
+ fallback: string;
+ bgColor: string;
+}) {
+ const [errorUrl, setErrorUrl] = useState();
+ const hasError = errorUrl === url;
+
+ if (url && !hasError) {
+ return (
+ setErrorUrl(url)}
+ referrerPolicy="no-referrer"
+ unoptimized
+ />
+ );
+ }
+
+ return (
+
+ {fallback}
+
+ );
+}
+
+export function ProfileContent() {
+ const t = useTranslations("userSettings");
+ const { data: user, isLoading: isUserLoading } = useAtomValue(currentUserAtom);
+ const { mutateAsync: updateUser, isPending } = useAtomValue(updateUserMutationAtom);
+
+ const [displayName, setDisplayName] = useState("");
+
+ useEffect(() => {
+ if (user) {
+ setDisplayName(user.display_name || "");
+ }
+ }, [user]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ try {
+ await updateUser({
+ display_name: displayName || null,
+ });
+ toast.success(t("profile_saved"));
+ } catch {
+ toast.error(t("profile_save_error"));
+ }
+ };
+
+ const hasChanges = displayName !== (user?.display_name || "");
+ const avatarBgColor = getUserAvatarColor(user?.email || "");
+
+ return (
+
+ {isUserLoading ? (
+
+
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/PromptsContent.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/PromptsContent.tsx
new file mode 100644
index 000000000..1d8cda12d
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/PromptsContent.tsx
@@ -0,0 +1,409 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import { AlertTriangle, Globe, Lock, MoreHorizontal, Pencil, Sparkles, Trash2 } from "lucide-react";
+import { useCallback, useState } from "react";
+import { toast } from "sonner";
+import {
+ createPromptMutationAtom,
+ deletePromptMutationAtom,
+ updatePromptMutationAtom,
+} from "@/atoms/prompts/prompts-mutation.atoms";
+import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Spinner } from "@/components/ui/spinner";
+import { Switch } from "@/components/ui/switch";
+import type { PromptRead } from "@/contracts/types/prompts.types";
+
+interface PromptFormData {
+ name: string;
+ prompt: string;
+ mode: "transform" | "explore";
+ is_public: boolean;
+}
+
+const EMPTY_FORM: PromptFormData = { name: "", prompt: "", mode: "transform", is_public: false };
+
+export function PromptsContent() {
+ const { data: prompts, isLoading, isError } = useAtomValue(promptsAtom);
+ const { mutateAsync: createPrompt } = useAtomValue(createPromptMutationAtom);
+ const { mutateAsync: updatePrompt } = useAtomValue(updatePromptMutationAtom);
+ const { mutateAsync: deletePrompt } = useAtomValue(deletePromptMutationAtom);
+
+ const [showForm, setShowForm] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [formData, setFormData] = useState(EMPTY_FORM);
+ const [isSaving, setIsSaving] = useState(false);
+ const [expandedId, setExpandedId] = useState(null);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [togglingPublicIds, setTogglingPublicIds] = useState>(new Set());
+
+ const handleSave = useCallback(async () => {
+ if (!formData.name.trim() || !formData.prompt.trim()) {
+ toast.error("Name and prompt are required");
+ return;
+ }
+
+ setIsSaving(true);
+ try {
+ if (editingId !== null) {
+ await updatePrompt({ id: editingId, ...formData });
+ } else {
+ await createPrompt(formData);
+ }
+ setShowForm(false);
+ setFormData(EMPTY_FORM);
+ setEditingId(null);
+ } catch {
+ // toast handled by mutation atoms
+ } finally {
+ setIsSaving(false);
+ }
+ }, [formData, editingId, createPrompt, updatePrompt]);
+
+ const handleEdit = useCallback((prompt: PromptRead) => {
+ setFormData({
+ name: prompt.name,
+ prompt: prompt.prompt,
+ mode: prompt.mode as "transform" | "explore",
+ is_public: prompt.is_public,
+ });
+ setEditingId(prompt.id);
+ setShowForm(true);
+ }, []);
+
+ const handleConfirmDelete = useCallback(async () => {
+ if (deleteTarget === null) return;
+ try {
+ await deletePrompt(deleteTarget);
+ } catch {
+ // toast handled by mutation atom
+ } finally {
+ setDeleteTarget(null);
+ }
+ }, [deleteTarget, deletePrompt]);
+
+ const handleTogglePublic = useCallback(
+ async (prompt: PromptRead) => {
+ if (togglingPublicIds.has(prompt.id)) return;
+ setTogglingPublicIds((prev) => new Set(prev).add(prompt.id));
+ try {
+ await updatePrompt({ id: prompt.id, is_public: !prompt.is_public });
+ } catch {
+ // toast handled by mutation atom
+ } finally {
+ setTogglingPublicIds((prev) => {
+ const next = new Set(prev);
+ next.delete(prompt.id);
+ return next;
+ });
+ }
+ },
+ [updatePrompt, togglingPublicIds]
+ );
+
+ const handleCancel = useCallback(() => {
+ setShowForm(false);
+ setFormData(EMPTY_FORM);
+ setEditingId(null);
+ }, []);
+
+ const list = prompts ?? [];
+
+ return (
+
+
+
+ Create prompt templates triggered with in
+ the chat composer.
+
+
{
+ setShowForm(true);
+ setEditingId(null);
+ setFormData(EMPTY_FORM);
+ }}
+ className="shrink-0 gap-1.5"
+ >
+ New
+
+
+
+
{
+ setShowForm(open);
+ if (!open) {
+ setFormData(EMPTY_FORM);
+ setEditingId(null);
+ }
+ }}
+ >
+
+
+ {editingId !== null ? "Edit prompt" : "New prompt"}
+
+ Create prompt templates triggered with / in the chat composer.
+
+
+
+
+
+ Name
+ setFormData((p) => ({ ...p, name: e.target.value }))}
+ placeholder="e.g. Fix grammar"
+ />
+
+
+
+
+
+ Mode
+
+ setFormData((p) => ({ ...p, mode: value as "transform" | "explore" }))
+ }
+ >
+
+
+
+
+
+ Transform — rewrites or modifies your text
+
+
+ Explore — answers a question about your text
+
+
+
+
+
+
+ setFormData((p) => ({ ...p, is_public: checked }))}
+ />
+
+ Share with community
+
+
+
+
+
+
+ Cancel
+
+
+
+ {editingId !== null ? "Update" : "Create"}
+
+ {isSaving && }
+
+
+
+
+
+ {isLoading && (
+
+ {["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {isError && (
+
+
+ Failed to load prompts
+ Please try refreshing the page.
+
+ )}
+
+ {!isLoading && !isError && list.length === 0 && !showForm && (
+
+
+
No prompts yet
+
+ Create prompts to quickly transform or explore text with /
+
+
+ )}
+
+ {!isLoading && !isError && list.length > 0 && (
+
+ {list.map((prompt) => (
+
+
+
+ {prompt.name}
+
+ {prompt.mode}
+
+ {prompt.is_public && (
+
+
+ Public
+
+ )}
+
+
+ {prompt.prompt}
+
+ {prompt.prompt.length > 100 && (
+
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"}
+
+ )}
+
+
+
+
+
+ Prompt actions
+
+
+
+ handleTogglePublic(prompt)}
+ disabled={togglingPublicIds.has(prompt.id)}
+ >
+ {togglingPublicIds.has(prompt.id) ? (
+
+ ) : prompt.is_public ? (
+
+ ) : (
+
+ )}
+ {prompt.is_public ? "Make private" : "Share with community"}
+
+ handleEdit(prompt)}>
+
+ Edit
+
+ setDeleteTarget(prompt.id)}
+ className="text-destructive focus:text-destructive"
+ >
+
+ Delete
+
+
+
+
+ ))}
+
+ )}
+
+
!open && setDeleteTarget(null)}
+ >
+
+
+ Delete prompt
+
+ This action cannot be undone. The prompt will be permanently removed.
+
+
+
+ Cancel
+ Delete
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/PurchaseHistoryContent.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/PurchaseHistoryContent.tsx
new file mode 100644
index 000000000..263a286c1
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/components/PurchaseHistoryContent.tsx
@@ -0,0 +1,217 @@
+"use client";
+
+import { useQueries } from "@tanstack/react-query";
+import { Coins, FileText, ReceiptText } from "lucide-react";
+import { useMemo } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import type { CreditPurchase, PagePurchase, PurchaseStatus } from "@/contracts/types/stripe.types";
+import { stripeApiService } from "@/lib/apis/stripe-api.service";
+import { cn } from "@/lib/utils";
+
+type PurchaseKind = "pages" | "credits";
+
+type UnifiedPurchase = {
+ id: string;
+ kind: PurchaseKind;
+ created_at: string;
+ status: PurchaseStatus;
+ /**
+ * Granted units. Interpretation depends on ``kind``:
+ * - ``"pages"`` — integer number of indexed pages (legacy history).
+ * - ``"credits"`` — integer micro-USD of credit (1_000_000 = $1.00).
+ * The ``Granted`` column formats accordingly.
+ */
+ granted: number;
+ amount_total: number | null;
+ currency: string | null;
+};
+
+const STATUS_STYLES: Record = {
+ completed: {
+ label: "Completed",
+ className: "bg-emerald-600 text-white border-transparent hover:bg-emerald-600",
+ },
+ pending: {
+ label: "Pending",
+ className: "bg-yellow-600 text-white border-transparent hover:bg-yellow-600",
+ },
+ failed: {
+ label: "Failed",
+ className: "bg-destructive text-white border-transparent hover:bg-destructive",
+ },
+};
+
+const KIND_META: Record<
+ PurchaseKind,
+ { label: string; icon: React.ComponentType<{ className?: string }>; iconClass: string }
+> = {
+ pages: {
+ label: "Pages",
+ icon: FileText,
+ iconClass: "text-sky-500",
+ },
+ credits: {
+ label: "Credits",
+ icon: Coins,
+ iconClass: "text-amber-500",
+ },
+};
+
+function formatDate(iso: string): string {
+ return new Date(iso).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+}
+
+function formatAmount(amount: number | null, currency: string | null): string {
+ if (amount == null) return "—";
+ const dollars = amount / 100;
+ const code = (currency ?? "usd").toUpperCase();
+ return `$${dollars.toFixed(2)} ${code}`;
+}
+
+function normalizePagePurchase(p: PagePurchase): UnifiedPurchase {
+ return {
+ id: p.id,
+ kind: "pages",
+ created_at: p.created_at,
+ status: p.status,
+ granted: p.pages_granted,
+ amount_total: p.amount_total,
+ currency: p.currency,
+ };
+}
+
+function normalizeCreditPurchase(p: CreditPurchase): UnifiedPurchase {
+ return {
+ id: p.id,
+ kind: "credits",
+ created_at: p.created_at,
+ status: p.status,
+ granted: p.credit_micros_granted,
+ amount_total: p.amount_total,
+ currency: p.currency,
+ };
+}
+
+function formatGranted(p: UnifiedPurchase): string {
+ if (p.kind === "credits") {
+ const dollars = p.granted / 1_000_000;
+ // Credit packs are always whole dollars at the moment, but future
+ // fractional grants (refunds, partial top-ups, auto-reload) shouldn't
+ // silently round to "$0".
+ if (dollars >= 1) return `$${dollars.toFixed(2)} of credit`;
+ if (dollars > 0) return `$${dollars.toFixed(3)} of credit`;
+ return "$0 of credit";
+ }
+ return p.granted.toLocaleString();
+}
+
+export function PurchaseHistoryContent() {
+ const results = useQueries({
+ queries: [
+ {
+ queryKey: ["stripe-purchases"],
+ queryFn: () => stripeApiService.getPagePurchases(),
+ },
+ {
+ queryKey: ["stripe-credit-purchases"],
+ queryFn: () => stripeApiService.getCreditPurchases(),
+ },
+ ],
+ });
+
+ const [pagesQuery, creditsQuery] = results;
+ const isLoading = pagesQuery.isLoading || creditsQuery.isLoading;
+
+ const purchases = useMemo(() => {
+ const pagePurchases = pagesQuery.data?.purchases ?? [];
+ const creditPurchases = creditsQuery.data?.purchases ?? [];
+ return [
+ ...pagePurchases.map(normalizePagePurchase),
+ ...creditPurchases.map(normalizeCreditPurchase),
+ ].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
+ }, [pagesQuery.data, creditsQuery.data]);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (purchases.length === 0) {
+ return (
+
+
+
No purchases yet
+
+ Your credit purchases will appear here after checkout.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Date
+ Type
+ Granted
+ Amount
+ Status
+
+
+
+ {purchases.map((p) => {
+ const statusStyle = STATUS_STYLES[p.status];
+ const kind = KIND_META[p.kind];
+ const KindIcon = kind.icon;
+ return (
+
+ {formatDate(p.created_at)}
+
+
+
+ {kind.label}
+
+
+
+ {formatGranted(p)}
+
+
+ {formatAmount(p.amount_total, p.currency)}
+
+
+
+ {statusStyle.label}
+
+
+
+ );
+ })}
+
+
+
+
+ Showing your {purchases.length} most recent purchase
+ {purchases.length !== 1 ? "s" : ""}.
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/desktop/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/desktop/page.tsx
new file mode 100644
index 000000000..a7b0d1d27
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/desktop/page.tsx
@@ -0,0 +1,5 @@
+import { DesktopContent } from "../components/DesktopContent";
+
+export default function Page() {
+ return ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/hotkeys/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/hotkeys/page.tsx
new file mode 100644
index 000000000..8f776f738
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/hotkeys/page.tsx
@@ -0,0 +1,5 @@
+import { HotkeysContent } from "../components/HotkeysContent";
+
+export default function Page() {
+ return ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout-shell.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout-shell.tsx
new file mode 100644
index 000000000..684b7d6f4
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout-shell.tsx
@@ -0,0 +1,188 @@
+"use client";
+
+import {
+ CircleUser,
+ Keyboard,
+ KeyRound,
+ Library,
+ MessageCircle,
+ Monitor,
+ ReceiptText,
+ ShieldCheck,
+ WandSparkles,
+ Workflow,
+} from "lucide-react";
+import Link from "next/link";
+import { useSelectedLayoutSegment } from "next/navigation";
+import { useTranslations } from "next-intl";
+import type React from "react";
+import { useCallback, useMemo, useState } from "react";
+import { Separator } from "@/components/ui/separator";
+import { usePlatform } from "@/hooks/use-platform";
+import { cn } from "@/lib/utils";
+
+export type UserSettingsTab =
+ | "profile"
+ | "api-key"
+ | "prompts"
+ | "community-prompts"
+ | "agent-permissions"
+ | "agent-status"
+ | "purchases"
+ | "desktop"
+ | "hotkeys"
+ | "messaging-channels";
+
+const DEFAULT_TAB: UserSettingsTab = "profile";
+
+interface UserSettingsLayoutShellProps {
+ workspaceId: string;
+ children: React.ReactNode;
+}
+
+export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsLayoutShellProps) {
+ const t = useTranslations("userSettings");
+ const { isDesktop } = usePlatform();
+ const segment = useSelectedLayoutSegment();
+ const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
+
+ const handleTabScroll = useCallback((e: React.UIEvent) => {
+ const el = e.currentTarget;
+ const atStart = el.scrollLeft <= 2;
+ const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
+ setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
+ }, []);
+
+ const navItems = useMemo(
+ () => [
+ {
+ value: "profile" as const,
+ label: t("profile_nav_label"),
+ icon: ,
+ },
+ {
+ value: "api-key" as const,
+ label: t("api_key_nav_label"),
+ icon: ,
+ },
+ {
+ value: "prompts" as const,
+ label: "My Prompts",
+ icon: ,
+ },
+ {
+ value: "community-prompts" as const,
+ label: "Community Prompts",
+ icon: ,
+ },
+ {
+ value: "agent-permissions" as const,
+ label: "Agent Permissions",
+ icon: ,
+ },
+ {
+ value: "agent-status" as const,
+ label: "Agent Status",
+ icon: ,
+ },
+ {
+ value: "messaging-channels" as const,
+ label: "Messaging Channels",
+ icon: ,
+ },
+ {
+ value: "purchases" as const,
+ label: "Purchase History",
+ icon: ,
+ },
+ ...(isDesktop
+ ? [
+ {
+ value: "desktop" as const,
+ label: "App Preferences",
+ icon: ,
+ },
+ {
+ value: "hotkeys" as const,
+ label: "Hotkeys",
+ icon: ,
+ },
+ ]
+ : []),
+ ],
+ [t, isDesktop]
+ );
+
+ const activeTab: UserSettingsTab =
+ segment && navItems.some((item) => item.value === segment)
+ ? (segment as UserSettingsTab)
+ : DEFAULT_TAB;
+ const selectedLabel = navItems.find((item) => item.value === activeTab)?.label ?? t("title");
+
+ const hrefFor = (tab: UserSettingsTab) => `/dashboard/${workspaceId}/user-settings/${tab}`;
+
+ return (
+
+
+
{t("title")}
+
+ {navItems.map((item) => (
+
+ {item.icon}
+ {item.label}
+
+ ))}
+
+
+
+ {navItems.map((item) => (
+
+ {item.icon}
+ {item.label}
+
+ ))}
+
+
+
+
+
+
+
{selectedLabel}
+
+
+
{children}
+
+
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout.tsx
new file mode 100644
index 000000000..46aa27ebe
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/layout.tsx
@@ -0,0 +1,17 @@
+import type React from "react";
+import { use } from "react";
+import { UserSettingsLayoutShell } from "./layout-shell";
+
+export default function UserSettingsLayout({
+ params,
+ children,
+}: {
+ params: Promise<{ workspace_id: string }>;
+ children: React.ReactNode;
+}) {
+ const { workspace_id } = use(params);
+
+ return (
+ {children}
+ );
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/messaging-channels/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/messaging-channels/page.tsx
new file mode 100644
index 000000000..335c7f084
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/messaging-channels/page.tsx
@@ -0,0 +1,5 @@
+import { MessagingChannelsContent } from "../components/MessagingChannelsContent";
+
+export default function Page() {
+ return ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/page.tsx
new file mode 100644
index 000000000..434d37e09
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/page.tsx
@@ -0,0 +1,10 @@
+import { redirect } from "next/navigation";
+
+export default async function UserSettingsPage({
+ params,
+}: {
+ params: Promise<{ workspace_id: string }>;
+}) {
+ const { workspace_id } = await params;
+ redirect(`/dashboard/${workspace_id}/user-settings/profile`);
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/profile/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/profile/page.tsx
new file mode 100644
index 000000000..9275ed26c
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/profile/page.tsx
@@ -0,0 +1,5 @@
+import { ProfileContent } from "../components/ProfileContent";
+
+export default function Page() {
+ return ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/prompts/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/prompts/page.tsx
new file mode 100644
index 000000000..559ce0a95
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/prompts/page.tsx
@@ -0,0 +1,5 @@
+import { PromptsContent } from "../components/PromptsContent";
+
+export default function Page() {
+ return ;
+}
diff --git a/surfsense_web/app/dashboard/[workspace_id]/user-settings/purchases/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/user-settings/purchases/page.tsx
new file mode 100644
index 000000000..55647fe29
--- /dev/null
+++ b/surfsense_web/app/dashboard/[workspace_id]/user-settings/purchases/page.tsx
@@ -0,0 +1,11 @@
+import { AutoReloadSettings } from "@/components/settings/auto-reload-settings";
+import { PurchaseHistoryContent } from "../components/PurchaseHistoryContent";
+
+export default function Page() {
+ return (
+
+ );
+}