-
Recent runs
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx
index 558a089ac..2f4eea7b8 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx
@@ -27,7 +27,7 @@ export function AutomationTriggersSection({
Triggers
- When this automation fires. v1 supports scheduled triggers only.
+ When this automation runs
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/execution-summary.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/execution-summary.tsx
index 5c4dc381c..82abce173 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/execution-summary.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/execution-summary.tsx
@@ -15,8 +15,8 @@ export function ExecutionSummary({ execution }: ExecutionSummaryProps) {
-
-
+
+
{execution.on_failure.length > 0 && (
-
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/inputs-schema-preview.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/inputs-schema-preview.tsx
index 29d79d99b..dce6ac4a7 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/inputs-schema-preview.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/inputs-schema-preview.tsx
@@ -1,5 +1,4 @@
"use client";
-import { JsonView } from "@/components/json-view";
import type { Inputs } from "@/contracts/types/automation.types";
interface InputsSchemaPreviewProps {
@@ -13,9 +12,63 @@ interface InputsSchemaPreviewProps {
* 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/[search_space_id]/automations/[automation_id]/components/plan-step-card.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/plan-step-card.tsx
index 27cecf3bf..15a285322 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/plan-step-card.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/plan-step-card.tsx
@@ -1,6 +1,4 @@
"use client";
-import { ArrowRightCircle, GitCommitHorizontal } from "lucide-react";
-import { JsonView } from "@/components/json-view";
import type { PlanStep } from "@/contracts/types/automation.types";
interface PlanStepCardProps {
@@ -9,62 +7,35 @@ interface PlanStepCardProps {
}
/**
- * Read-only view of one plan step. Renders the step_id + action prominently,
- * then a definition list of the per-step knobs, and finally the params as
- * formatted JSON. Editable mode is out of scope here — definition edits live
- * on the (future) raw-JSON path.
+ * 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}
-
{step.step_id}
-
-
{step.action}
-
-
-
- {(step.when ||
- step.output_as ||
- step.max_retries != null ||
- step.timeout_seconds != null) && (
-
- {step.when && (
- {step.when}} />
- )}
- {step.output_as && (
- {step.output_as}}
- />
- )}
- {step.max_retries != null && (
-
- )}
- {step.timeout_seconds != null && (
-
- )}
-
- )}
-
-
-
-
- Params
-
-
-
-
+
+
{title}
+ {details.length > 0 ? (
+
+ {details.map((detail) => (
+
+ ))}
+
+ ) : null}
);
}
-function DefRow({ label, value }: { label: string; value: React.ReactNode }) {
+function DefRow({ label, value }: { label: string; value: string }) {
return (
- {label}:
@@ -72,3 +43,104 @@ function DefRow({ label, value }: { label: string; value: React.ReactNode }) {
);
}
+
+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/[search_space_id]/automations/[automation_id]/components/run-details-panel.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-details-panel.tsx
index 1a54ac0e5..ab82589dc 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-details-panel.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/run-details-panel.tsx
@@ -53,7 +53,11 @@ export function RunDetailsPanel({
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, {
+ const {
+ data: run,
+ isLoading,
+ error,
+ } = useAutomationRun(automationId, runId, {
enabled: isTerminal,
});
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx
index 681877523..6e6f84bd0 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx
@@ -1,15 +1,36 @@
"use client";
import { useAtomValue } from "jotai";
-import { AlertCircle, CalendarClock, Clock, Pencil, Save, Trash2 } from "lucide-react";
+import { AlertCircle, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { useState } from "react";
import { updateTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
-import { JsonView } from "@/components/json-view";
+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 { formatRelativeDate, formatRelativeFutureDate } from "@/lib/format-date";
+import { formatRelativeFutureDate } from "@/lib/format-date";
+import {
+ DEFAULT_SCHEDULE,
+ fromCron,
+ type ScheduleFrequency,
+ toCron,
+} from "@/lib/automations/schedule-builder";
+import { TimezoneCombobox } from "../../components/builder/timezone-combobox";
import { DeleteTriggerDialog } from "./delete-trigger-dialog";
interface TriggerCardProps {
@@ -19,27 +40,58 @@ interface TriggerCardProps {
canDelete: boolean;
}
+type SimpleFrequency = Extract | "custom";
+
interface TriggerDraft {
- params: Record;
- static_inputs: Record;
+ 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 {
- params: trigger.params,
- static_inputs: trigger.static_inputs ?? {},
+ 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:
- * - type icon + human-readable schedule + timezone
- * - last_fired_at / next_fire_at hints
- * - static_inputs as formatted JSON (when present)
- * - enable toggle + remove button + inline edit (each gated independently)
+ * - human-readable schedule
+ * - compact enable toggle
+ * - dropdown actions for edit/remove
*
- * Inline edit covers ``params`` and ``static_inputs`` — the two fields the
- * backend ``PATCH /triggers/[id]`` endpoint accepts beyond ``enabled``.
+ * 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) {
@@ -51,10 +103,9 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
const [issues, setIssues] = useState([]);
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) : trigger.type;
- const triggerLabel = cron ? `${human} · ${tz}` : trigger.type;
- const hasStaticInputs = Object.keys(trigger.static_inputs ?? {}).length > 0;
+ const triggerLabel = human;
+ const showActions = (canUpdate && !isEditing) || canDelete;
async function handleToggle(checked: boolean) {
await updateTrigger({
@@ -77,7 +128,22 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
async function saveEdit() {
setIssues([]);
- const result = triggerUpdateRequest.safeParse(draft);
+ 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}`)
@@ -98,134 +164,206 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
return (
<>
-
-
-
-
-
-
- {human}
- · {tz}
-
- {cron &&
{cron}}
-
-
+
+
+
{human}
-
+
{canUpdate && (
-
-
- {trigger.enabled ? "Enabled" : "Off"}
-
-
-
+
)}
- {canUpdate && !isEditing && (
-
- )}
- {canDelete && (
-
+ {showActions && (
+
+
+
+
+
+ {canUpdate && !isEditing && (
+
+
+ Edit
+
+ )}
+ {canDelete && (
+ setDeleteOpen(true)}>
+
+ Delete
+
+ )}
+
+
)}
-
- {isEditing ? (
- <>
-
-
setDraft(next as TriggerDraft)}
- collapsed={false}
- />
+ {!isEditing && trigger.next_fire_at ? (
+
+
+ Next fire:
+
+
+ {formatRelativeFutureDate(trigger.next_fire_at)}
+
+
+ ) : null}
+
+ {isEditing ? (
+
+
+
+
+
- {issues.length > 0 && (
-
-
-
- {issues.length === 1 ? "1 issue" : `${issues.length} issues`}
-
-
+ {draft.frequency === "hourly" ? (
+
+
+
+ setDraft((prev) => ({
+ ...prev,
+ minute: clampInt(event.target.value, 0, 59),
+ }))
+ }
+ />
+
+ ) : draft.frequency !== "custom" ? (
+
+
+ {
+ const [hour, minute] = event.target.value.split(":");
+ setDraft((prev) => ({
+ ...prev,
+ hour: clampInt(hour, 0, 23),
+ minute: clampInt(minute, 0, 59),
+ }));
+ }}
+ />
+
+ ) : (
+
+
+
+ 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}
))}
-
- )}
+
+
+ )}
-
-
-
-
- >
- ) : (
- <>
- {(trigger.last_fired_at || trigger.next_fire_at) && (
-
- {trigger.next_fire_at && (
-
- )}
- {trigger.last_fired_at && (
-
- )}
-
- )}
-
- {hasStaticInputs && (
-
- )}
- >
- )}
-
+
+
+
+
+
+ ) : null}
{canDelete && (
@@ -240,35 +378,3 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
>
);
}
-
-function TimeRow({
- label,
- iso,
- tense,
- highlight = false,
-}: {
- label: string;
- iso: string;
- tense: "past" | "future";
- highlight?: boolean;
-}) {
- const formatted = tense === "future" ? formatRelativeFutureDate(iso) : formatRelativeDate(iso);
- return (
- <>
-
-
-
- {label}
-
-
-
- {formatted}
-
- >
- );
-}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx
index 2c9db217d..c05bff7d9 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/automation-edit-content.tsx
@@ -51,9 +51,17 @@ export function AutomationEditContent({ searchSpaceId, automationId }: Automatio
}
return (
- <>
-
-
- >
+
(
+
+ )}
+ />
);
}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx
index 6b2a31822..ca477220e 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/edit/components/automation-edit-header.tsx
@@ -1,15 +1,21 @@
"use client";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
+import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import type { Automation } from "@/contracts/types/automation.types";
interface AutomationEditHeaderProps {
automation: Automation;
searchSpaceId: number;
+ modeSwitcher?: ReactNode;
}
-export function AutomationEditHeader({ automation, searchSpaceId }: AutomationEditHeaderProps) {
+export function AutomationEditHeader({
+ automation,
+ searchSpaceId,
+ modeSwitcher,
+}: AutomationEditHeaderProps) {
const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`;
return (
@@ -20,11 +26,11 @@ export function AutomationEditHeader({ automation, searchSpaceId }: AutomationEd
Back to automation
-
+
Edit automation
-
{automation.name}
+ {modeSwitcher ?
{modeSwitcher}
: null}
);
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx
index 756221d38..d9c949058 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/automations-content.tsx
@@ -1,5 +1,6 @@
"use client";
-import { ShieldAlert } from "lucide-react";
+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";
@@ -60,9 +61,10 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
loading={false}
canCreate={perms.canCreate}
/>
-
-
Couldn't load automations. {error.message}
-
+
+
+ Couldn't load automations {error.message}
+
>
);
}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row-actions.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row-actions.tsx
index 229a417dc..95ee23445 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row-actions.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row-actions.tsx
@@ -8,7 +8,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { AutomationSummary } from "@/contracts/types/automation.types";
@@ -58,25 +57,21 @@ export function AutomationRowActions({
-
+
{canToggle && (
{pauseLabel}
)}
- {canToggle && canDelete && }
{canDelete && (
- setDeleteOpen(true)}
- className="text-destructive focus:text-destructive"
- >
+ setDeleteOpen(true)}>
Delete
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row.tsx
index a59fb4527..74c95cee4 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-row.tsx
@@ -26,35 +26,30 @@ export function AutomationRow({
canDelete,
}: AutomationRowProps) {
return (
-
-
-
-
- {automation.name}
-
- {automation.description && (
-
- {automation.description}
-
- )}
-
+
+
+
+ {automation.name}
+
-
+
-
+
{formatRelativeDate(automation.updated_at)}
-
-
+
+
);
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-status-badge.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-status-badge.tsx
index ecf171e78..22e1be222 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-status-badge.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automation-status-badge.tsx
@@ -1,5 +1,4 @@
"use client";
-import { Archive, CircleDot, Pause } from "lucide-react";
import type { AutomationStatus } from "@/contracts/types/automation.types";
import { cn } from "@/lib/utils";
@@ -8,41 +7,37 @@ interface AutomationStatusBadgeProps {
className?: string;
}
-// Color + icon per status. Active = green, paused = amber, archived = muted.
+// Small borderless status pills, matching model-selector badges.
const STATUS_STYLES: Record<
AutomationStatus,
- { label: string; icon: typeof CircleDot; classes: string }
+ { label: string; classes: string }
> = {
active: {
label: "Active",
- icon: CircleDot,
classes:
- "bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-900/50",
+ "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300",
},
paused: {
label: "Paused",
- icon: Pause,
classes:
- "bg-amber-50 text-amber-700 border border-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900/50",
+ "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
},
archived: {
label: "Archived",
- icon: Archive,
- classes: "bg-muted text-muted-foreground border border-border/60",
+ classes: "bg-muted text-muted-foreground",
},
};
export function AutomationStatusBadge({ status, className }: AutomationStatusBadgeProps) {
- const { label, icon: Icon, classes } = STATUS_STYLES[status];
+ const { label, classes } = STATUS_STYLES[status];
return (
-
{label}
);
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx
index cc54c5e94..b2e7b2532 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-empty-state.tsx
@@ -1,5 +1,5 @@
"use client";
-import { MessageSquarePlus, SquarePen, Workflow } from "lucide-react";
+import { Workflow } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
@@ -28,16 +28,14 @@ export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsE
{canCreate ? (
-
) : (
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx
index 137727f60..5c1fcb507 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-header.tsx
@@ -1,5 +1,4 @@
"use client";
-import { MessageSquarePlus, SquarePen } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
@@ -41,17 +40,16 @@ export function AutomationsHeader({
{canCreate && showCreateCta && (
-
-
-
- Create manually
-
+
+ Create manually
-
-
- Create via chat
-
+ Create via chat
)}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx
index ec3aeeef5..8314a5179 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/automations-table.tsx
@@ -1,5 +1,5 @@
"use client";
-import { Activity, CalendarDays, Workflow } from "lucide-react";
+import { CalendarDays, Info, Workflow } 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";
@@ -37,7 +37,7 @@ export function AutomationsTable({
-
+
Status
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/advanced-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/advanced-section.tsx
index 740f199af..110de57f6 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/advanced-section.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/advanced-section.tsx
@@ -58,7 +58,7 @@ export function AdvancedSection({
return (
-
+
-
+
-
+
{BACKOFF_OPTIONS.map((option) => (
{option.label}
@@ -105,7 +105,7 @@ export function AdvancedSection({
-
+
{CONCURRENCY_OPTIONS.map((option) => (
{option.label}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx
index 39904dfa0..59967080f 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-builder-form.tsx
@@ -1,8 +1,8 @@
"use client";
import { useAtomValue } from "jotai";
-import { Code2, LayoutList, Save } from "lucide-react";
-import Link from "next/link";
+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 {
@@ -12,9 +12,12 @@ import {
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,
@@ -35,7 +38,6 @@ import {
hasResolvedModels,
hydrateForm,
} from "@/lib/automations/builder-schema";
-import { cn } from "@/lib/utils";
import { AdvancedSection } from "./advanced-section";
import { AutomationModelFields } from "./automation-model-fields";
import { BasicsSection } from "./basics-section";
@@ -56,6 +58,7 @@ interface AutomationBuilderFormProps {
* eligibility itself is now owned by the in-form pickers.
*/
submitDisabledReason?: string;
+ renderModeSwitcher?: (modeSwitcher: ReactNode) => ReactNode;
}
type Mode = "form" | "json";
@@ -78,6 +81,7 @@ export function AutomationBuilderForm({
searchSpaceId,
automation,
submitDisabledReason,
+ renderModeSwitcher,
}: AutomationBuilderFormProps) {
const router = useRouter();
const { mutateAsync: createAutomation } = useAtomValue(createAutomationMutationAtom);
@@ -97,7 +101,7 @@ export function AutomationBuilderForm({
return {
mode: "json" as Mode,
form: createEmptyForm(),
- notice: `This automation ${result.reason}, which the form can't show. Edit it as JSON below.`,
+ 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 };
@@ -116,11 +120,6 @@ export function AutomationBuilderForm({
const [submitting, setSubmitting] = useState(false);
- const cancelHref =
- mode === "edit" && automation
- ? `/dashboard/${searchSpaceId}/automations/${automation.id}`
- : `/dashboard/${searchSpaceId}/automations`;
-
// 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.
@@ -192,7 +191,7 @@ export function AutomationBuilderForm({
// 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." };
+ return { ok: false, issues: [], notice: "Add a definition before switching to the form" };
}
const name =
@@ -210,7 +209,7 @@ export function AutomationBuilderForm({
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}.` };
+ : { ok: false, issues: [], notice: `Can't show in the form: it ${h.reason}` };
}
function validateForm(): Record | null {
@@ -328,119 +327,133 @@ export function AutomationBuilderForm({
: 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 (
-
-
- (activeMode === "form" ? undefined : switchToForm())}
- />
- (activeMode === "json" ? undefined : switchToJson())}
- />
-
-
+ {renderModeSwitcher ? (
+ renderModeSwitcher(modeSwitcher)
+ ) : (
+
{modeSwitcher}
+ )}
{activeMode === "json" ? (
-
-
-
-
-
+
) : (
-
-
-
- Basics
-
-
-
-
-
-
-
-
- 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 })}
- />
-
+
+
+
+
+
+
+ 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
@@ -452,12 +465,14 @@ export function AutomationBuilderForm({
)}
- {rootError && {rootError}
}
+ {rootError && (
+
+
+ {rootError}
+
+ )}
-
- Cancel
-
{submitBlocked ? (
@@ -470,7 +485,6 @@ export function AutomationBuilderForm({
className="cursor-not-allowed opacity-50"
onClick={(event) => event.preventDefault()}
>
-
{submitLabel}
@@ -481,14 +495,11 @@ export function AutomationBuilderForm({
type="button"
size="sm"
disabled={submitting}
+ className="relative"
onClick={() => (activeMode === "json" ? submitJson() : submitForm())}
>
- {submitting ? (
-
- ) : (
-
- )}
- {submitLabel}
+ {submitLabel}
+ {submitting && }
)}
@@ -496,34 +507,6 @@ export function AutomationBuilderForm({
);
}
-function ModeButton({
- active,
- icon: Icon,
- label,
- onClick,
-}: {
- active: boolean;
- icon: typeof Code2;
- label: string;
- onClick: () => void;
-}) {
- return (
-
-
- {label}
-
- );
-}
-
function extractTriggers(raw: unknown): HydratableTrigger[] {
if (!Array.isArray(raw)) return [];
return raw.map((entry) => {
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-model-fields.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-model-fields.tsx
index 8ca8d839c..2c4a0bf60 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-model-fields.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/automation-model-fields.tsx
@@ -118,15 +118,14 @@ const ModelSelectField = memo(function ModelSelectField({
if (kind.options.length === 0) {
return (
-
+
No eligible models
-
- Automations need a premium or your own (BYOK) model. Set one up in{" "}
+
+ Use a premium model or your own (BYOK) model in{" "}
role settings
- .
@@ -155,7 +154,7 @@ const ModelSelectField = memo(function ModelSelectField({
)}
-
+
{premium.length > 0 ? (
Premium
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/builder-summary.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/builder-summary.tsx
index 21a77cb5f..55059ab53 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/builder-summary.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/builder-summary.tsx
@@ -1,5 +1,5 @@
"use client";
-import { CalendarClock, CheckCircle2, ListOrdered, type LucideIcon, XCircle } from "lucide-react";
+import { Dot } from "lucide-react";
import { type BuilderForm, scheduleToCron } from "@/lib/automations/builder-schema";
import { describeCron } from "@/lib/automations/describe-cron";
@@ -12,85 +12,70 @@ interface BuilderSummaryProps {
* chat ``AutomationDraftPreview`` so the two creation paths feel consistent.
*/
export function BuilderSummary({ form }: BuilderSummaryProps) {
- const scheduleLabel = form.schedule
- ? `${describeCron(scheduleToCron(form.schedule))} · ${form.timezone}`
- : "No schedule — won't run automatically";
+ 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 (
-
-
-
{form.name.trim() || "Untitled automation"}
- {form.description?.trim() && (
-
{form.description.trim()}
- )}
+
+
-
+
-
-
- {form.tasks.map((task, index) => (
- -
-
- {index + 1}
-
-
-
- {task.query.trim() || (
- No instructions yet
- )}
+
+
+ {scheduleDescription ? (
+
+ {scheduleDescription}
+
+ {form.timezone}
+
+ ) : (
+ No schedule — won't run automatically
+ )}
+
+
+
+
+ {visibleTasks.map((task, index) => (
+ -
+ {index + 1}.
+
+ {task.query.trim() || "No instructions yet"}
- {task.mentions.length > 0 && (
-
- {task.mentions.map((mention) => (
-
- @{mention.title}
-
- ))}
-
- )}
-
-
- ))}
-
-
+
+ ))}
+ {hiddenTaskCount > 0 && (
+ - +{hiddenTaskCount} more tasks
+ )}
+
+
-
- {form.unattended ? (
-
- ) : (
-
- )}
- {form.unattended ? "Runs without approval prompts" : "Will reject approval prompts"}
+
+ {form.unattended ? "Runs without approval prompts" : "Approval prompts are rejected"}
+
);
}
-function Section({
- icon: Icon,
+function SummaryRow({
label,
children,
}: {
- icon: LucideIcon;
label: string;
children: React.ReactNode;
}) {
return (
-
-
-
- {label}
-
- {children}
+
);
}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/json-mode-panel.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/json-mode-panel.tsx
index 1f25f8a61..412533d36 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/json-mode-panel.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/json-mode-panel.tsx
@@ -1,6 +1,7 @@
"use client";
-import { AlertCircle } from "lucide-react";
+import { AlertCircle, TriangleAlert } from "lucide-react";
import { JsonView } from "@/components/json-view";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
interface JsonModePanelProps {
value: Record
;
@@ -19,32 +20,32 @@ export function JsonModePanel({ value, issues, notice, onChange }: JsonModePanel
return (
{notice && (
-
- {notice}
-
+
+
+ {notice}
+
)}
-
- onChange(next as Record)}
- collapsed={false}
- />
-
+
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}
- ))}
-
-
+
+
+ {issues.length === 1 ? "1 issue" : `${issues.length} issues`}
+
+
+ {issues.map((issue) => (
+ - {issue}
+ ))}
+
+
+
)}
);
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/schedule-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/schedule-section.tsx
index 401b4f5cb..810984acd 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/schedule-section.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/schedule-section.tsx
@@ -1,5 +1,5 @@
"use client";
-import { CalendarClock, CalendarOff, Plus, X } from "lucide-react";
+import { CalendarClock, CalendarOff, Dot, Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -70,17 +70,18 @@ export function ScheduleSection({
return (
-
+
{label}
- · {timezone}
+
+ {timezone}
onScheduleChange(null)}
>
@@ -135,7 +136,7 @@ function PresetEditor({ model, onChange, onSwitchToCron }: PresetEditorProps) {
-
+
{FREQUENCY_OPTIONS.map((option) => (
{option.label}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/task-item.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/task-item.tsx
index 55b9ea406..856f253d2 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/task-item.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/task-item.tsx
@@ -1,10 +1,10 @@
"use client";
-import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
+import * as AccordionPrimitive from "@radix-ui/react-accordion";
+import { ChevronDown, ChevronRight, ChevronUp, Trash2 } from "lucide-react";
import {
Accordion,
AccordionContent,
AccordionItem,
- AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -43,7 +43,7 @@ export function TaskItem({
onRemove,
}: TaskItemProps) {
return (
-
+
@@ -103,27 +103,30 @@ export function TaskItem({
-
+
+
Advanced
-
+
+
+
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/timezone-combobox.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/timezone-combobox.tsx
index bc3b97542..ed0808bb3 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/timezone-combobox.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/timezone-combobox.tsx
@@ -35,22 +35,26 @@ export function TimezoneCombobox({ value, onChange }: TimezoneComboboxProps) {
variant="outline"
role="combobox"
aria-expanded={open}
- className="w-full justify-between font-normal"
+ className="w-full justify-between border-popover-border bg-transparent font-normal hover:bg-transparent"
>
{value || "Select timezone"}
-
-
+
+
No timezone found.
-
+
{timezones.map((tz) => (
{
onChange(tz);
setOpen(false);
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/unattended-toggle.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/unattended-toggle.tsx
index ba665445f..861f22204 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/unattended-toggle.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/components/builder/unattended-toggle.tsx
@@ -1,7 +1,5 @@
"use client";
-import { Info } from "lucide-react";
import { Switch } from "@/components/ui/switch";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface UnattendedToggleProps {
checked: boolean;
@@ -15,26 +13,15 @@ interface UnattendedToggleProps {
*/
export function UnattendedToggle({ checked, onChange }: UnattendedToggleProps) {
return (
-
+
Run without asking for approvals
-
-
-
-
-
-
-
- Automations run unattended. With this off, any approval the agent asks for is
- rejected, which can stall a step.
-
-
- Auto-approve actions the agent would normally pause to confirm.
+ Tasks run automatically without asking for confirmation
-
-
- >
+ (
+
+ )}
+ />
);
}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-new-header.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-new-header.tsx
index ccfbbc9fa..de9e2412b 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-new-header.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/new/components/automation-new-header.tsx
@@ -1,38 +1,38 @@
"use client";
-import { ArrowLeft, MessageSquarePlus } from "lucide-react";
+import { ArrowLeft } from "lucide-react";
import Link from "next/link";
+import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
interface AutomationNewHeaderProps {
searchSpaceId: number;
+ modeSwitcher?: ReactNode;
}
-export function AutomationNewHeader({ searchSpaceId }: AutomationNewHeaderProps) {
+export function AutomationNewHeader({ searchSpaceId, modeSwitcher }: AutomationNewHeaderProps) {
return (
-
-
-
- Back to automations
-
-
+
+
+
+
+ Back to automations
+
+
+ {modeSwitcher ?
{modeSwitcher}
: null}
+
New automation
- Set up a task and a schedule. Prefer natural language? Use chat instead.
+ Configure the task, schedule, and execution settings for this automation.
-
-
-
- Switch to chat
-
-
+ {modeSwitcher ?
{modeSwitcher}
: null}
);
diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/page.tsx
index b77cb20f4..0502d2310 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/automations/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/automations/page.tsx
@@ -8,7 +8,7 @@ export default async function AutomationsPage({
const { search_space_id } = await params;
return (
-
+
);
diff --git a/surfsense_web/app/dashboard/[search_space_id]/buy-more/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/buy-more/page.tsx
index 74bcaff2e..b4ec015b7 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/buy-more/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/buy-more/page.tsx
@@ -1,6 +1,5 @@
"use client";
-import { motion } from "motion/react";
import { useState } from "react";
import { BuyPagesContent } from "@/components/settings/buy-pages-content";
import { BuyTokensContent } from "@/components/settings/buy-tokens-content";
@@ -17,12 +16,7 @@ export default function BuyMorePage() {
const [activeTab, setActiveTab] = useState
("pages");
return (
-
+
{
@@ -49,6 +43,6 @@ export default function BuyMorePage() {
-
+
);
}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx
index 759539ce3..7c9fcb1a0 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx
@@ -47,14 +47,9 @@ export function DashboardClientLayout({
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const isOnboardingComplete = useCallback(() => {
- // Check that both LLM IDs are set (including 0 for Auto mode)
- return (
- preferences.agent_llm_id !== null &&
- preferences.agent_llm_id !== undefined &&
- preferences.document_summary_llm_id !== null &&
- preferences.document_summary_llm_id !== undefined
- );
- }, [preferences]);
+ // Check that the Agent LLM ID is set, including 0 for Auto mode.
+ return preferences.agent_llm_id !== null && preferences.agent_llm_id !== undefined;
+ }, [preferences.agent_llm_id]);
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);
@@ -100,7 +95,6 @@ export function DashboardClientLayout({
search_space_id: Number(searchSpaceId),
data: {
agent_llm_id: firstGlobalConfig.id,
- document_summary_llm_id: firstGlobalConfig.id,
},
});
diff --git a/surfsense_web/app/dashboard/[search_space_id]/more-pages/loading.tsx b/surfsense_web/app/dashboard/[search_space_id]/more-pages/loading.tsx
deleted file mode 100644
index ccb3b35e3..000000000
--- a/surfsense_web/app/dashboard/[search_space_id]/more-pages/loading.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { Skeleton } from "@/components/ui/skeleton";
-
-export default function Loading() {
- return (
-
-
-
-
- );
-}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
index f8ca9bbc2..75cfa4184 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
@@ -18,6 +18,7 @@ import { disabledToolsAtom } from "@/atoms/agent-tools/agent-tools.atoms";
import {
clearTargetCommentIdAtom,
currentThreadAtom,
+ setCurrentThreadMetadataAtom,
setTargetCommentIdAtom,
} from "@/atoms/chat/current-thread.atom";
import {
@@ -36,7 +37,7 @@ 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, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
+import { removeChatTabAtom, syncChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import {
EditMessageDialog,
@@ -50,6 +51,7 @@ import {
TokenUsageProvider,
} from "@/components/assistant-ui/token-usage-context";
import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
import {
type HitlDecision,
PendingInterruptProvider,
@@ -64,6 +66,7 @@ import {
} 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 { getBearerToken } from "@/lib/auth-utils";
@@ -100,8 +103,6 @@ import {
appendMessage,
createThread,
getRegenerateUrl,
- getThreadFull,
- getThreadMessages,
type ThreadListItem,
type ThreadListResponse,
type ThreadRecord,
@@ -119,7 +120,7 @@ import {
trackChatMessageSent,
trackChatResponseReceived,
} from "@/lib/posthog/events";
-import Loading from "../loading";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
const MobileEditorPanel = dynamic(
() =>
@@ -287,11 +288,78 @@ function computeFallbackTurnCancellingRetryDelay(attempt: number): number {
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 [isInitializing, setIsInitializing] = useState(true);
- const [threadId, setThreadId] = useState(null);
+ 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);
@@ -375,12 +443,14 @@ export default function NewChatPage() {
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
- const setCurrentThreadState = useSetAtom(currentThreadAtom);
+ 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);
@@ -402,9 +472,11 @@ export default function NewChatPage() {
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(threadId);
+ useChatSessionStateSync(activeThreadId);
const { data: membersData } = useAtomValue(membersAtom);
const handleSyncedMessagesUpdate = useCallback(
@@ -465,7 +537,7 @@ export default function NewChatPage() {
[isRunning, membersData]
);
- useMessagesSync(threadId, handleSyncedMessagesUpdate);
+ useMessagesSync(activeThreadId, handleSyncedMessagesUpdate);
// Extract search_space_id from URL params
const searchSpaceId = useMemo(() => {
@@ -479,19 +551,7 @@ export default function NewChatPage() {
// 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(threadId);
-
- // Extract chat_id from URL params
- const urlChatId = useMemo(() => {
- const id = params.chat_id;
- 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;
- }, [params.chat_id]);
+ const { items: agentActionItems } = useAgentActionsQuery(activeThreadId);
const handleChatFailure = useCallback(
async ({
@@ -630,14 +690,19 @@ export default function NewChatPage() {
});
}, []);
- // Initialize thread and load messages
- // For new chats (no urlChatId), we use lazy creation - thread is created on first message
- const initializeThread = useCallback(async () => {
- setIsInitializing(true);
+ const hydratedMessagesRef = useRef<{
+ threadId: number | null;
+ data: typeof threadMessagesQuery.data;
+ }>({ threadId: null, data: undefined });
- // Reset all state when switching between chats/search spaces to prevent stale data
+ // 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([]);
- setThreadId(null);
setCurrentThread(null);
setMentionedDocuments([]);
tokenUsageStore.clear();
@@ -647,82 +712,105 @@ export default function NewChatPage() {
closeEditorPanel();
// Note: agent-action data is keyed by threadId in react-query so
// switching threads naturally swaps caches; no explicit reset.
-
- try {
- if (urlChatId > 0) {
- // Thread exists - load thread data and messages
- setThreadId(urlChatId);
-
- // Load thread data (for visibility info) and messages in parallel
- const [threadData, messagesResponse] = await Promise.all([
- getThreadFull(urlChatId),
- getThreadMessages(urlChatId),
- ]);
-
- setCurrentThread(threadData);
-
- if (messagesResponse.messages && messagesResponse.messages.length > 0) {
- const loadedMessages = reconcileInterruptedAssistantMessages(
- messagesResponse.messages
- ).map(convertToThreadMessage);
- setMessages(loadedMessages);
-
- for (const msg of messagesResponse.messages) {
- if (msg.token_usage) {
- tokenUsageStore.set(`msg-${msg.id}`, msg.token_usage as TokenUsageData);
- }
- }
-
- const restoredDocsMap: Record = {};
- for (const msg of messagesResponse.messages) {
- if (msg.role === "user") {
- const docs = extractMentionedDocuments(msg.content);
- if (docs.length > 0) {
- restoredDocsMap[`msg-${msg.id}`] = docs;
- }
- }
- }
- if (Object.keys(restoredDocsMap).length > 0) {
- setMessageDocumentsMap(restoredDocsMap);
- }
- }
- }
- // For new chats (urlChatId === 0), don't create thread yet
- // Thread will be created lazily when user sends first message
- // This improves UX (instant load) and avoids orphan threads
- } catch (error) {
- console.error("[NewChatPage] Failed to initialize thread:", error);
- if (urlChatId > 0 && error instanceof NotFoundError) {
- removeChatTab(urlChatId);
- if (typeof window !== "undefined") {
- window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
- }
- toast.error("This chat was deleted.");
- return;
- }
- // Keep threadId as null - don't use Date.now() as it creates an invalid ID
- // that will cause 404 errors on subsequent API calls
- setThreadId(null);
- setCurrentThread(null);
- toast.error("Failed to load chat. Please try again.");
- } finally {
- setIsInitializing(false);
- }
}, [
urlChatId,
- setMessageDocumentsMap,
setMentionedDocuments,
+ setMessageDocumentsMap,
+ tokenUsageStore,
closeReportPanel,
closeEditorPanel,
- removeChatTab,
- searchSpaceId,
+ ]);
+
+ 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.search_space_id ?? searchSpaceId}/new-chat/${thread.id}`,
+ searchSpaceId: thread.search_space_id ?? searchSpaceId,
+ visibility: thread.visibility,
+ hasComments: thread.has_comments ?? false,
+ });
+ }
+ }, [activeThreadId, searchSpaceId, 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
+ );
+ 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,
+ setMessageDocumentsMap,
+ threadMessagesQuery.data,
tokenUsageStore,
]);
- // Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same)
useEffect(() => {
- initializeThread();
- }, [initializeThread]);
+ 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/${searchSpaceId}/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,
+ searchSpaceId,
+ threadDetailQuery.error,
+ threadMessagesQuery.error,
+ ]);
// Prefetch document titles for @ mention picker
// Runs when user lands on page so data is ready when they type @
@@ -750,7 +838,7 @@ export default function NewChatPage() {
const readAndApplyCommentId = () => {
const params = new URLSearchParams(window.location.search);
const raw = params.get("commentId");
- if (raw && !isInitializing) {
+ if (raw && activeThreadId) {
const commentId = Number.parseInt(raw, 10);
if (!Number.isNaN(commentId)) {
setTargetCommentId(commentId);
@@ -768,17 +856,42 @@ export default function NewChatPage() {
window.removeEventListener("popstate", readAndApplyCommentId);
clearTargetCommentId();
};
- }, [isInitializing, setTargetCommentId, clearTargetCommentId]);
+ }, [activeThreadId, setTargetCommentId, clearTargetCommentId]);
// Sync current thread state to atom
useEffect(() => {
- setCurrentThreadState((prev) => ({
- ...prev,
- id: currentThread?.id ?? null,
- visibility: currentThread?.visibility ?? null,
- hasComments: currentThread?.has_comments ?? false,
- }));
- }, [currentThread, setCurrentThreadState]);
+ if (!currentThread) {
+ if (activeThreadId) {
+ return;
+ }
+ setCurrentThreadMetadata({
+ id: null,
+ searchSpaceId: null,
+ visibility: null,
+ hasComments: false,
+ });
+ return;
+ }
+
+ const visibility =
+ currentThreadState.id === currentThread.id && currentThreadState.visibility !== null
+ ? currentThreadState.visibility
+ : currentThread.visibility;
+
+ setCurrentThreadMetadata({
+ id: currentThread.id,
+ searchSpaceId: currentThread.search_space_id ?? searchSpaceId,
+ visibility,
+ hasComments: currentThread.has_comments ?? false,
+ });
+ }, [
+ activeThreadId,
+ currentThread,
+ currentThreadState.id,
+ currentThreadState.visibility,
+ searchSpaceId,
+ setCurrentThreadMetadata,
+ ]);
// Cleanup on unmount - abort any in-flight requests
useEffect(() => {
@@ -862,6 +975,8 @@ export default function NewChatPage() {
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(searchSpaceId, currentThreadId);
@@ -1369,6 +1484,14 @@ export default function NewChatPage() {
} finally {
setIsRunning(false);
abortControllerRef.current = null;
+ if (currentThreadId) {
+ void queryClient.invalidateQueries({
+ queryKey: cacheKeys.threads.messages(currentThreadId),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: cacheKeys.threads.detail(currentThreadId),
+ });
+ }
}
},
[
@@ -1717,6 +1840,12 @@ export default function NewChatPage() {
} finally {
setIsRunning(false);
abortControllerRef.current = null;
+ void queryClient.invalidateQueries({
+ queryKey: cacheKeys.threads.messages(resumeThreadId),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: cacheKeys.threads.detail(resumeThreadId),
+ });
}
},
[
@@ -2210,6 +2339,12 @@ export default function NewChatPage() {
} finally {
setIsRunning(false);
abortControllerRef.current = null;
+ void queryClient.invalidateQueries({
+ queryKey: cacheKeys.threads.messages(threadId),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: cacheKeys.threads.detail(threadId),
+ });
}
},
[
@@ -2396,22 +2531,25 @@ export default function NewChatPage() {
onCancel: cancelRun,
});
- // Show loading state only when loading an existing thread
- if (isInitializing) {
- return ;
- }
+ 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;
- // Show error state only if we tried to load an existing thread but failed
- // For new chats (urlChatId === 0), threadId being null is expected (lazy creation)
- if (!threadId && urlChatId > 0) {
+ if (shouldShowThreadLoadError) {
return (
Failed to load chat
{
- setIsInitializing(true);
- initializeThread();
+ void Promise.all([threadDetailQuery.refetch(), threadMessagesQuery.refetch()]);
}}
>
Try Again
@@ -2430,8 +2568,13 @@ export default function NewChatPage() {
onSubmit={handleApprovalSubmit}
>
-
+
+ {isThreadMessagesLoading ? (
+
+
+
+ ) : null}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
index 3e3f41deb..6c1393a23 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/onboard/page.tsx
@@ -54,10 +54,7 @@ export default function OnboardPage() {
// Check if onboarding is already complete (including 0 for Auto mode)
const isOnboardingComplete =
- preferences.agent_llm_id !== null &&
- preferences.agent_llm_id !== undefined &&
- preferences.document_summary_llm_id !== null &&
- preferences.document_summary_llm_id !== undefined;
+ preferences.agent_llm_id !== null && preferences.agent_llm_id !== undefined;
useEffect(() => {
if (!preferencesLoading && isOnboardingComplete) {
@@ -83,7 +80,6 @@ export default function OnboardPage() {
search_space_id: searchSpaceId,
data: {
agent_llm_id: firstGlobalConfig.id,
- document_summary_llm_id: firstGlobalConfig.id,
},
});
@@ -120,7 +116,6 @@ export default function OnboardPage() {
search_space_id: searchSpaceId,
data: {
agent_llm_id: newConfig.id,
- document_summary_llm_id: newConfig.id,
},
});
diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx
index 3bc2459c1..9245d7bdd 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx
@@ -236,35 +236,36 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
if (accessLoading || membersLoading) {
return (
-
-
-
- Invite members
-
-
-
- Active invites
-
-
-
-
-
-
- members
+
+
+
Members
+
+
+
+
+ Invite members
+
+
+
+ Active invites
+
+
+
+
@@ -319,51 +320,54 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
return (
-
- {canInvite &&
- (rolesLoading ? (
-
-
- Invite members
-
- ) : (
-
- ))}
- {canInvite &&
- (invitesLoading ? (
-
-
- Active invites
-
-
-
-
- ) : (
- activeInvites.length > 0 && (
+
+
+
Members
+
+ {members.length} {members.length === 1 ? "member" : "members"}
+
+
+ {canInvite && (
+
+ {rolesLoading ? (
+
+
+ Invite members
+
+ ) : (
+
+ )}
+ {invitesLoading ? (
+
+
+ Active invites
+
+
+
+
+ ) : (
- )
- ))}
-
- {members.length} {members.length === 1 ? "member" : "members"}
-
+ )}
+
+ )}
@@ -859,7 +863,11 @@ function AllInvitesDialog({
return (