import { useChatModels } from "@agent-native/core/client/agent-chat";
import { agentNativePath } from "@agent-native/core/client/api-path";
import { ChangelogSettingsCard } from "@agent-native/core/client/changelog";
import {
callAction,
useActionMutation,
useChangeVersions,
} from "@agent-native/core/client/hooks";
import { LanguagePicker, useT } from "@agent-native/core/client/i18n";
import { TeamPage } from "@agent-native/core/client/org";
import {
AccountSettingsCard,
SettingsTabsPage,
useAgentSettingsTabs,
type SettingsSearchEntry,
type SettingsTabItem,
} from "@agent-native/core/client/settings";
import type {
Alias,
AutomationAction,
AutomationRule,
UserSettings,
} from "@shared/types";
import {
IconUsers,
IconPlus,
IconPencil,
IconTrash,
IconLoader2,
IconBolt,
IconX,
IconChartBar,
IconCircleCheck,
IconCircleX,
IconClock,
IconPlayerPlay,
IconSignature,
IconFilter,
IconInfoCircle,
IconMessage2,
} from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, useEffect, useRef, useMemo } from "react";
import { useSearchParams } from "react-router";
import { toast } from "sonner";
import { GmailFiltersSection } from "@/components/settings/GmailFiltersSection";
import { SnippetsSection } from "@/components/settings/SnippetsSection";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
useAliases,
useCreateAlias,
useUpdateAlias,
useDeleteAlias,
} from "@/hooks/use-aliases";
import {
useAutomations,
useCreateAutomation,
useUpdateAutomation,
useDeleteAutomation,
} from "@/hooks/use-automations";
import { useSettings, useUpdateSettings } from "@/hooks/use-emails";
import { useNavigationState } from "@/hooks/use-navigation-state";
import { cn } from "@/lib/utils";
import changelog from "../../CHANGELOG.md?raw";
// ─── Alias Edit Row ───────────────────────────────────────────────────────────
function AliasEditRow({
alias,
onSave,
onCancel,
isPending,
}: {
alias?: Alias;
onSave: (name: string, emails: string[]) => void;
onCancel: () => void;
isPending?: boolean;
}) {
const t = useT();
const [name, setName] = useState(alias?.name ?? "");
const [emailsText, setEmailsText] = useState(alias?.emails.join("\n") ?? "");
const handleSave = () => {
const emails = emailsText
.split("\n")
.map((e) => e.trim())
.filter(Boolean);
if (!name.trim() || emails.length === 0) return;
onSave(name.trim(), emails);
};
return (
{t("settings.aliasName")}
setName(e.target.value)}
placeholder={t("settings.aliasNamePlaceholder")}
className="px-3 py-1.5 text-[13px] placeholder:text-muted-foreground/40"
/>
{t("settings.recipientsOnePerLine")}
{isPending && }
{t("settings.save")}
{t("settings.cancel")}
);
}
// ─── Alias Row ────────────────────────────────────────────────────────────────
function AliasRow({
alias,
isEditing,
onEdit,
onCancelEdit,
}: {
alias: Alias;
isEditing: boolean;
onEdit: () => void;
onCancelEdit: () => void;
}) {
const t = useT();
const updateAlias = useUpdateAlias();
const deleteAlias = useDeleteAlias();
const rowRef = useRef(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useEffect(() => {
if (isEditing && rowRef.current) {
rowRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}, [isEditing]);
const handleSave = (name: string, emails: string[]) => {
updateAlias.mutate(
{ id: alias.id, name, emails },
{ onSuccess: onCancelEdit },
);
};
const handleDelete = () => {
setShowDeleteConfirm(true);
};
const confirmDelete = () => {
deleteAlias.mutate(alias.id);
setShowDeleteConfirm(false);
};
if (isEditing) {
return (
);
}
return (
<>
{alias.name}
{alias.emails.length}{" "}
{t(
alias.emails.length === 1
? "settings.personSingular"
: "settings.peoplePlural",
{ count: alias.emails.length },
)}
{alias.emails.join(", ")}
{t("settings.editAlias")}
{deleteAlias.isPending ? (
) : (
)}
{t("settings.deleteAlias")}
{t("settings.deleteAlias")}
{t("settings.deleteAliasDescription", { name: alias.name })}
{t("settings.cancel")}
{t("settings.delete")}
>
);
}
// ─── Aliases Section ──────────────────────────────────────────────────────────
function AliasesSection() {
const t = useT();
const { data: aliases = [], isLoading } = useAliases();
const createAlias = useCreateAlias();
const [searchParams, setSearchParams] = useSearchParams();
const [editingId, setEditingId] = useState(null);
const [showNewForm, setShowNewForm] = useState(false);
// Handle ?alias= query param — open that alias in edit mode
const aliasParam = searchParams.get("alias");
useEffect(() => {
if (aliasParam && aliases.length > 0) {
const exists = aliases.find((a) => a.id === aliasParam);
if (exists) {
setEditingId(aliasParam);
// Clear the param so it doesn't re-trigger on every render
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete("alias");
return next;
});
}
}
}, [aliasParam, aliases]); // eslint-disable-line react-hooks/exhaustive-deps
const handleCreate = (name: string, emails: string[]) => {
createAlias.mutate(
{ name, emails },
{
onSuccess: () => setShowNewForm(false),
},
);
};
return (
{/* Header */}
{t("settings.aliases")}
{t("settings.aliasesDescription")}
{
setShowNewForm(true);
setEditingId(null);
}}
>
{t("settings.newAlias")}
{/* Content */}
{/* New alias form at top */}
{showNewForm && (
setShowNewForm(false)}
isPending={createAlias.isPending}
/>
)}
{/* Loading state */}
{isLoading &&
Array.from({ length: 3 }).map((_, i) => (
))}
{/* Empty state */}
{!isLoading && aliases.length === 0 && !showNewForm && (
{t("settings.noAliases")}
)}
{/* Alias list */}
{aliases.map((alias) => (
{
setEditingId(alias.id);
setShowNewForm(false);
}}
onCancelEdit={() => setEditingId(null)}
/>
))}
);
}
// ─── Action Badge ─────────────────────────────────────────────────────────────
function ActionBadge({ action }: { action: AutomationAction }) {
const label =
action.type === "label" ? `label: ${action.labelName}` : action.type;
return (
{label}
);
}
// ─── Action Builder ───────────────────────────────────────────────────────────
const ACTION_TYPES = [
{ value: "label", labelKey: "settings.applyLabel" },
{ value: "archive", labelKey: "settings.archive" },
{ value: "mark_read", labelKey: "settings.markRead" },
{ value: "star", labelKey: "settings.star" },
{ value: "trash", labelKey: "settings.trash" },
] as const;
function ActionBuilder({
actions,
onChange,
}: {
actions: AutomationAction[];
onChange: (actions: AutomationAction[]) => void;
}) {
const t = useT();
const addAction = () => {
onChange([...actions, { type: "label", labelName: "" }]);
};
const removeAction = (index: number) => {
onChange(actions.filter((_, i) => i !== index));
};
const updateAction = (index: number, updated: AutomationAction) => {
const next = [...actions];
next[index] = updated;
onChange(next);
};
return (
{actions.map((action, idx) => (
{
const type = value as AutomationAction["type"];
if (type === "label") {
updateAction(idx, { type: "label", labelName: "" });
} else {
updateAction(idx, { type } as AutomationAction);
}
}}
>
{ACTION_TYPES.map((actionType) => (
{t(actionType.labelKey)}
))}
{action.type === "label" && (
updateAction(idx, { type: "label", labelName: e.target.value })
}
placeholder={t("settings.labelName")}
className="flex-1 h-8 px-2 text-[13px] placeholder:text-muted-foreground/40"
/>
)}
removeAction(idx)}
className="p-1 text-muted-foreground/40 hover:text-destructive"
>
))}
{t("settings.addAction")}
);
}
// ─── Automation Edit Row ──────────────────────────────────────────────────────
function AutomationEditRow({
rule,
onSave,
onCancel,
isPending,
}: {
rule?: AutomationRule;
onSave: (data: {
name: string;
condition: string;
actions: AutomationAction[];
}) => void;
onCancel: () => void;
isPending?: boolean;
}) {
const t = useT();
const [name, setName] = useState(rule?.name ?? "");
const [condition, setCondition] = useState(rule?.condition ?? "");
const [actions, setActions] = useState(
rule?.actions ?? [{ type: "label", labelName: "" }],
);
const handleSave = () => {
if (!name.trim() || !condition.trim() || actions.length === 0) return;
// Validate label actions have names
const valid = actions.every(
(a) => a.type !== "label" || (a.type === "label" && a.labelName.trim()),
);
if (!valid) return;
onSave({ name: name.trim(), condition: condition.trim(), actions });
};
return (
{t("settings.ruleName")}
setName(e.target.value)}
placeholder={t("settings.ruleNamePlaceholder")}
className="px-3 py-1.5 text-[13px] placeholder:text-muted-foreground/40"
/>
{t("settings.conditionNaturalLanguage")}
{isPending && }
{t("settings.save")}
{t("settings.cancel")}
);
}
// ─── Automation Row ───────────────────────────────────────────────────────────
function AutomationRow({
rule,
isEditing,
onEdit,
onCancelEdit,
}: {
rule: AutomationRule;
isEditing: boolean;
onEdit: () => void;
onCancelEdit: () => void;
}) {
const t = useT();
const updateAutomation = useUpdateAutomation();
const deleteAutomation = useDeleteAutomation();
const rowRef = useRef(null);
useEffect(() => {
if (isEditing && rowRef.current) {
rowRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}, [isEditing]);
const handleSave = (data: {
name: string;
condition: string;
actions: AutomationAction[];
}) => {
updateAutomation.mutate(
{ id: rule.id, ...data },
{ onSuccess: onCancelEdit },
);
};
const handleToggle = (enabled: boolean) => {
updateAutomation.mutate({ id: rule.id, enabled });
};
if (isEditing) {
return (
);
}
return (
{rule.name}
{rule.condition}
{rule.actions.map((action, idx) => (
))}
{t("settings.editRule")}
deleteAutomation.mutate(rule.id)}
disabled={deleteAutomation.isPending}
className="h-7 w-7 p-0"
>
{deleteAutomation.isPending ? (
) : (
)}
{t("settings.deleteRule")}
);
}
// ─── Framework Triggers Subsection ──────────────────────────────────────────
interface FrameworkTrigger {
id: string;
name: string;
triggerType: string;
event?: string;
condition?: string;
mode: string;
domain?: string;
enabled: boolean;
lastStatus?: string;
lastRun?: string;
lastError?: string;
body: string;
}
function TriggersSubsection() {
const t = useT();
const { data: triggers = [], isLoading } = useQuery({
queryKey: ["framework-triggers-mail"],
queryFn: async () => {
const res = await fetch(agentNativePath("/_agent-native/automations"));
if (!res.ok) return [];
const all: FrameworkTrigger[] = await res.json();
// Filter to mail domain triggers only (event-based)
return all.filter(
(t) =>
t.domain === "mail" ||
(t.triggerType === "event" && t.event && t.event.startsWith("mail.")),
);
},
staleTime: 30_000,
});
if (isLoading) {
return (
);
}
if (triggers.length === 0) {
return (
{t("settings.noEventAutomations")}
{t("settings.eventAutomationsPrompt")}
);
}
return (
{triggers.map((trigger) => {
const StatusIcon =
trigger.lastStatus === "success"
? IconCircleCheck
: trigger.lastStatus === "error"
? IconCircleX
: trigger.lastStatus === "running"
? IconLoader2
: IconClock;
const statusColor =
trigger.lastStatus === "success"
? "text-green-400"
: trigger.lastStatus === "error"
? "text-red-400"
: trigger.lastStatus === "running"
? "text-yellow-400 animate-spin"
: "text-muted-foreground/40";
return (
{trigger.name}
{!trigger.enabled && (
{t("settings.disabled")}
)}
{trigger.event && (
{t("settings.on")}{" "}
{trigger.event}
{trigger.condition && (
{" "}
{t("settings.when")} "{trigger.condition}"
)}
)}
{trigger.body}
{trigger.lastRun && (
{t("settings.lastRun")}{" "}
{new Date(trigger.lastRun).toLocaleString(undefined, {
dateStyle: "short",
timeStyle: "short",
})}
{trigger.lastError && (
— {trigger.lastError}
)}
)}
);
})}
);
}
// ─── Automations Section ─────────────────────────────────────────────────────
function AutomationsSection() {
const t = useT();
const { data: rules = [], isLoading } = useAutomations();
const createAutomation = useCreateAutomation();
const [editingId, setEditingId] = useState(null);
const [showNewForm, setShowNewForm] = useState(false);
const { availableModels, defaultModel } = useChatModels({
storageKey: "agent-native:mail-automations:model",
});
const modelOptions = useMemo(() => {
const configuredGroups = availableModels.filter(
(group) => group.configured,
);
const groups =
configuredGroups.length > 0 ? configuredGroups : availableModels;
return groups.flatMap((group) =>
group.models.map((model) => ({
value: `${group.engine}::${model}`,
engine: group.engine,
model,
label: `${group.label} / ${model}`,
})),
);
}, [availableModels]);
// Refetch on any settings write or agent action so agent-driven changes
// (e.g. update-automation-settings) show up without a manual refresh.
const settingsSync = useChangeVersions(["settings", "action"]);
const { data: autoSettings } = useQuery({
queryKey: ["automation-settings", settingsSync],
queryFn: async () => {
try {
return await callAction(
"get-automation-settings",
{},
{ method: "GET" },
);
} catch {
return { engine: "anthropic", model: defaultModel };
}
},
staleTime: 30_000,
placeholderData: (prev) => prev,
});
const queryClient = useQueryClient();
const selectedModel = autoSettings?.model || defaultModel;
const selectedEngine =
autoSettings?.engine ||
modelOptions.find((option) => option.model === selectedModel)?.engine ||
"anthropic";
const selectedValue =
modelOptions.find(
(option) =>
option.engine === selectedEngine && option.model === selectedModel,
)?.value ||
modelOptions[0]?.value ||
"loading";
const handleModelChange = async (value: string) => {
const [engine, model] = value.split("::");
if (!engine || !model) return;
queryClient.setQueryData(["automation-settings"], { engine, model });
await callAction(
"update-automation-settings",
{ engine, model },
{ method: "PUT" },
);
};
const handleCreate = (data: {
name: string;
condition: string;
actions: AutomationAction[];
}) => {
createAutomation.mutate(data, {
onSuccess: () => setShowNewForm(false),
});
};
return (
{/* Header */}
{t("settings.automations")}
{t("settings.automationsDescription")}
{modelOptions.length === 0 ? (
{t("settings.loadingModels")}
) : (
modelOptions.map((m) => (
{m.label}
))
)}
{
setShowNewForm(true);
setEditingId(null);
}}
>
{t("settings.newRule")}
{/* Content */}
{/* New rule form */}
{showNewForm && (
setShowNewForm(false)}
isPending={createAutomation.isPending}
/>
)}
{/* Loading state */}
{isLoading &&
Array.from({ length: 4 }).map((_, i) => (
))}
{/* Empty state */}
{!isLoading && rules.length === 0 && !showNewForm && (
{t("settings.noAutomationRules")}
{t("settings.noAutomationRulesDescription")}
)}
{/* Rule list */}
{rules.map((rule) => (
{
setEditingId(rule.id);
setShowNewForm(false);
}}
onCancelEdit={() => setEditingId(null)}
/>
))}
{/* Event-triggered automations (framework-level triggers) */}
{t("settings.eventTriggers")}
{t("settings.eventTriggersDescription")}
);
}
// ─── Drafting Section ────────────────────────────────────────────────────────
function DraftingSection() {
const t = useT();
const { data: settings, isLoading } = useSettings();
const updateSettings = useUpdateSettings();
const queryClient = useQueryClient();
const [signature, setSignature] = useState("");
const [writingStyle, setWritingStyle] = useState("");
const importSignature = useActionMutation("import-gmail-signature", {
onSuccess: (result) => {
setSignature(result.signature);
queryClient.setQueryData(["settings"], (prev) =>
prev ? { ...prev, signature: result.signature } : prev,
);
if (result.imported) {
toast(t("settings.importedSignature", { account: result.account }));
} else {
toast(t("settings.noGmailSignature", { account: result.account }));
}
},
onError: (error) =>
toast.error(
error instanceof Error
? error.message
: t("settings.importSignatureFailed"),
),
});
useEffect(() => {
if (!settings) return;
setSignature(settings.signature ?? "");
setWritingStyle(settings.writingStyle ?? "");
}, [settings?.signature, settings?.writingStyle]); // eslint-disable-line react-hooks/exhaustive-deps
const savedSignature = settings?.signature ?? "";
const savedWritingStyle = settings?.writingStyle ?? "";
const isDirty =
signature !== savedSignature || writingStyle !== savedWritingStyle;
const handleSave = () => {
updateSettings.mutate(
{
signature: signature.trim(),
writingStyle: writingStyle.trim(),
},
{
onSuccess: () => toast(t("settings.draftingSettingsSaved")),
onError: (error) =>
toast.error(
error instanceof Error
? error.message
: t("settings.draftingSettingsSaveFailed"),
),
},
);
};
return (
{t("settings.drafting")}
{t("settings.draftingDescription")}
{isLoading ? (
<>
>
) : (
<>
{t("settings.signature")}
importSignature.mutate({})}
disabled={importSignature.isPending}
>
{importSignature.isPending && (
)}
{t("settings.importFromGmail")}
{t("settings.writingStyle")}
{updateSettings.isPending && (
)}
{t("settings.saveDraftingSettings")}
{isDirty && (
{
setSignature(savedSignature);
setWritingStyle(savedWritingStyle);
}}
>
{t("settings.reset")}
)}
>
)}
);
}
function TrackingRow({
title,
description,
checked,
disabled,
onCheckedChange,
}: {
title: string;
description: string;
checked: boolean;
disabled?: boolean;
onCheckedChange: (value: boolean) => void;
}) {
return (
);
}
function TrackingSection() {
const t = useT();
const { data: settings, isLoading } = useSettings();
const updateSettings = useUpdateSettings();
const tracking = settings?.tracking ?? { opens: false, clicks: false };
const update = (patch: Partial<{ opens: boolean; clicks: boolean }>) => {
updateSettings.mutate({
tracking: { ...tracking, ...patch },
});
};
return (
{t("settings.tracking")}
{t("settings.trackingDescription")}
{isLoading ? (
<>
>
) : (
<>
update({ opens: v })}
/>
update({ clicks: v })}
/>
>
)}
);
}
// ─── Slack Intake Section ───────────────────────────────────────────────────
type SlackStatus = {
enabled: boolean;
configured: boolean;
webhookUrl?: string;
error?: string;
};
function SlackIntakeSection() {
const t = useT();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["integration-status", "slack"],
queryFn: async () => {
const res = await fetch(
agentNativePath("/_agent-native/integrations/slack/status"),
);
if (!res.ok) throw new Error(t("settings.slackLoadFailed"));
return res.json();
},
retry: false,
});
const toggle = useMutation({
mutationFn: async (enabled: boolean) => {
const res = await fetch(
agentNativePath(
`/_agent-native/integrations/slack/${enabled ? "enable" : "disable"}`,
),
{ method: "POST" },
);
if (!res.ok) throw new Error(t("settings.slackUpdateFailed"));
return res.json();
},
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: ["integration-status", "slack"],
}),
});
const slackStatusDescription = data?.configured
? t("settings.slackConfigured")
: t("settings.slackNeedsCredentials");
return (
{t("settings.slackIntake")}
{t("settings.slackDescription")}
{isLoading ? (
<>
>
) : (
<>
{data?.configured ? (
) : (
)}
{data?.enabled
? t("settings.enabled")
: t("settings.disabled")}
{slackStatusDescription}
{data?.configured && data?.error && (
{data.error}
)}
toggle.mutate(!data?.enabled)}
>
{toggle.isPending && (
)}
{data?.enabled ? t("settings.disable") : t("settings.enable")}
{data?.configured && data?.webhookUrl && (
{t("settings.slackPostEndpoint")}
{t("settings.slackPostEndpointHelp")}
)}
>
)}
);
}
// ─── What's New Section ──────────────────────────────────────────────────────
function GeneralSection() {
const t = useT();
return (
{t("settings.general")}
{t("settings.generalDescription")}
{t("settings.languageTitle")}
{t("settings.languageDescription")}
);
}
function WhatsNewSection() {
const t = useT();
return (
{t("settings.whatsNew")}
{t("settings.whatsNewDescription")}
);
}
// ─── Settings Page ────────────────────────────────────────────────────────────
export function SettingsPage() {
const t = useT();
const [searchParams, setSearchParams] = useSearchParams();
const navState = useNavigationState();
const agentSettingsTabs = useAgentSettingsTabs();
const [activeSection, setActiveSection] = useState("general");
const mailTabs = useMemo(
() => [
{
id: "drafting",
label: t("settings.drafting"),
icon: IconSignature,
content: ,
keywords: "signature writing style compose reply draft",
},
{
id: "snippets",
label: t("settings.snippets"),
icon: IconMessage2,
content: ,
keywords: "snippets templates canned responses shortcuts",
},
{
id: "automations",
label: t("settings.automations"),
icon: IconBolt,
group: "automation",
content: ,
keywords: "automations rules triggers events labels model",
},
{
id: "gmail-filters",
label: t("settings.gmailFilters"),
icon: IconFilter,
group: "integrations",
content: ,
keywords: "gmail filters import rules",
},
{
id: "aliases",
label: t("settings.aliases"),
icon: IconUsers,
content: ,
keywords: "aliases groups distribution lists recipients",
},
{
id: "tracking",
label: t("settings.tracking"),
icon: IconChartBar,
content: ,
keywords: "tracking opens clicks pixel analytics",
},
{
id: "slack",
label: t("settings.slack"),
icon: IconBolt,
group: "integrations",
content: ,
keywords: "slack intake integration webhook",
},
],
[t],
);
const extraTabs = useMemo(
() => [...mailTabs, ...agentSettingsTabs],
[agentSettingsTabs, mailTabs],
);
const generalSearchEntries = useMemo(
() => [
{
id: "mail-language",
label: t("settings.languageTitle"),
keywords: "language locale translation i18n",
hash: "language",
},
],
[t],
);
const validSectionIds = useMemo(() => {
const ids = new Set(["general", "account", "team", "whats-new"]);
for (const tab of extraTabs) ids.add(tab.id);
return ids;
}, [extraTabs]);
// Deep links arrive as /settings?section= (e.g. from the agent's
// navigate action). Adopt that section, then strip the param so later tab
// switches aren't overridden by a stale query value.
useEffect(() => {
const section = searchParams.get("section");
if (!section || !validSectionIds.has(section)) return;
setActiveSection(section);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete("section");
return next;
},
{ replace: true },
);
}, [searchParams, setSearchParams, validSectionIds]);
// Keep app state aware of the visible settings section for the agent.
useEffect(() => {
navState.sync({ view: "settings", settingsSection: activeSection });
}, [activeSection]); // eslint-disable-line react-hooks/exhaustive-deps
return (
}
className="flex-1"
generalLabel={t("settings.general")}
teamLabel={t("settings.team")}
whatsNewLabel={t("settings.whatsNew")}
extraTabs={extraTabs}
generalSearchEntries={generalSearchEntries}
value={activeSection}
onValueChange={setActiveSection}
general={ }
team={
}
whatsNew={ }
/>
);
}