refactor(nexus): consolidate Settings into single-column scroll page (phase 13)

Rewrites InstanceGeneralSettings.tsx into the consolidated /instance/
settings/general destination per spec §8.1 — a single-column scroll
that stacks eight section cards (Workspace, Local AI, Cloud Providers,
Skills, Routines, Telegram, About, Danger Zone).

App.tsx nested settings sub-routes collapse:
 - /instance/settings/heartbeats → redirect to /general
 - /instance/settings/experimental → redirect to /general
The /instance/settings/plugins tree is left intact: it is a
plugin-system surface with its own pages, not a settings sub-page.

Deletes InstanceSidebar.tsx (already unmounted by Phase 8),
InstanceSettings.tsx (scheduler heartbeats dashboard, folded out per
spec), and InstanceExperimentalSettings.tsx (experimental toggles are
now part of the Workspace section).

Also fixes ToastInput shape (title/tone instead of message/type) in
CloudProvidersSection and TelegramSection, and drops the unavailable
cronExpression access in RoutinesSection since the RoutineListItem
trigger Pick does not include it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 13:28:40 +00:00
parent 6aeadeeb11
commit 1a0d611cb1
8 changed files with 46 additions and 730 deletions

View file

@ -39,8 +39,6 @@ const CompanyExport = lazy(() => import("./pages/CompanyExport").then(m => ({ de
const CompanyImport = lazy(() => import("./pages/CompanyImport").then(m => ({ default: m.CompanyImport })));
const DesignGuide = lazy(() => import("./pages/DesignGuide").then(m => ({ default: m.DesignGuide })));
const InstanceGeneralSettings = lazy(() => import("./pages/InstanceGeneralSettings").then(m => ({ default: m.InstanceGeneralSettings })));
const InstanceSettings = lazy(() => import("./pages/InstanceSettings").then(m => ({ default: m.InstanceSettings })));
const InstanceExperimentalSettings = lazy(() => import("./pages/InstanceExperimentalSettings").then(m => ({ default: m.InstanceExperimentalSettings })));
const PluginManager = lazy(() => import("./pages/PluginManager").then(m => ({ default: m.PluginManager })));
const PluginSettings = lazy(() => import("./pages/PluginSettings").then(m => ({ default: m.PluginSettings })));
const PluginPage = lazy(() => import("./pages/PluginPage").then(m => ({ default: m.PluginPage })));
@ -382,8 +380,10 @@ export function App() {
<Route path="instance/settings" element={<Layout />}>
<Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<InstanceGeneralSettings />} />
<Route path="heartbeats" element={<InstanceSettings />} />
<Route path="experimental" element={<InstanceExperimentalSettings />} />
{/* Phase 13: nested settings sub-routes collapse into /general. */}
<Route path="heartbeats" element={<Navigate to="/instance/settings/general" replace />} />
<Route path="experimental" element={<Navigate to="/instance/settings/general" replace />} />
{/* Plugins remain top-level pages owned by the plugin system, not settings sub-pages. */}
<Route path="plugins" element={<PluginManager />} />
<Route path="plugins/:pluginId" element={<PluginSettings />} />
</Route>

View file

@ -1,53 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { Clock3, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react";
import { NavLink } from "@/lib/router";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { SidebarNavItem } from "./SidebarNavItem";
export function InstanceSidebar() {
const { data: plugins } = useQuery({
queryKey: queryKeys.plugins.all,
queryFn: () => pluginsApi.list(),
});
return (
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
<div className="flex items-center gap-2 px-3 h-12 shrink-0">
<Settings className="h-4 w-4 text-muted-foreground shrink-0 ml-1" />
<span className="flex-1 text-sm font-bold text-foreground truncate">
Instance Settings
</span>
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/instance/settings/general" label="General" icon={SlidersHorizontal} end />
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
<SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} />
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
{(plugins ?? []).length > 0 ? (
<div className="ml-4 mt-1 flex flex-col gap-0.5 border-l border-border/70 pl-3">
{(plugins ?? []).map((plugin) => (
<NavLink
key={plugin.id}
to={`/instance/settings/plugins/${plugin.id}`}
className={({ isActive }) =>
[
"rounded-md px-2 py-1.5 text-xs transition-colors",
isActive
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
].join(" ")
}
>
{plugin.manifestJson.displayName ?? plugin.packageName}
</NavLink>
))}
</div>
) : null}
</div>
</nav>
</aside>
);
}

View file

@ -36,7 +36,7 @@ function ProviderRow({ id, label, hasKey, companyId }: ProviderRowProps) {
onSuccess: async () => {
setEditing(false);
setValue("");
pushToast({ type: "success", message: `${label} saved.` });
pushToast({ tone: "success", title: `${label} saved.` });
if (companyId) {
await queryClient.invalidateQueries({
queryKey: queryKeys.secrets.list(companyId),
@ -45,8 +45,8 @@ function ProviderRow({ id, label, hasKey, companyId }: ProviderRowProps) {
},
onError: (error) => {
pushToast({
type: "error",
message: error instanceof Error ? error.message : `Failed to save ${label}.`,
tone: "error",
title: error instanceof Error ? error.message : `Failed to save ${label}.`,
});
},
});

View file

@ -58,11 +58,7 @@ export function RoutinesSection() {
{topRoutines.map((routine) => {
const primaryTrigger =
routine.triggers.find((t) => t.enabled) ?? routine.triggers[0] ?? null;
const scheduleLabel =
primaryTrigger?.label ??
(primaryTrigger?.cronExpression
? `cron: ${primaryTrigger.cronExpression}`
: null);
const scheduleLabel = primaryTrigger?.label ?? primaryTrigger?.kind ?? null;
return (
<li
key={routine.id}

View file

@ -37,15 +37,15 @@ export function TelegramSection() {
setEditing(false);
setToken("");
pushToast({
type: "success",
message: `Telegram bot @${data.botUsername} saved.`,
tone: "success",
title: `Telegram bot @${data.botUsername} saved.`,
});
await queryClient.invalidateQueries({ queryKey: TELEGRAM_STATUS_KEY });
},
onError: (error) => {
pushToast({
type: "error",
message:
tone: "error",
title:
error instanceof Error ? error.message : "Failed to save Telegram token.",
});
},

View file

@ -1,139 +0,0 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical } from "lucide-react";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
export function InstanceExperimentalSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([
{ label: "Instance Settings" },
{ label: "Experimental" },
]);
}, [setBreadcrumbs]);
const experimentalQuery = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const toggleMutation = useMutation({
mutationFn: async (patch: { enableIsolatedWorkspaces?: boolean; autoRestartDevServerWhenIdle?: boolean }) =>
instanceSettingsApi.updateExperimental(patch),
onSuccess: async () => {
setActionError(null);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }),
queryClient.invalidateQueries({ queryKey: queryKeys.health }),
]);
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to update experimental settings.");
},
});
if (experimentalQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading experimental settings...</div>;
}
if (experimentalQuery.error) {
return (
<div className="text-sm text-destructive">
{experimentalQuery.error instanceof Error
? experimentalQuery.error.message
: "Failed to load experimental settings."}
</div>
);
}
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
return (
<div className="max-w-4xl space-y-6">
<div className="space-y-2">
<div className="flex items-center gap-2">
<FlaskConical className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Experimental</h1>
</div>
<p className="text-sm text-muted-foreground">
Opt into features that are still being evaluated before they become default behavior.
</p>
</div>
{actionError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
)}
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Enable Isolated Workspaces</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Show execution workspace controls in project configuration and allow isolated workspace behavior for new
and existing issue runs.
</p>
</div>
<button
type="button"
data-slot="toggle"
aria-label="Toggle isolated workspaces experimental setting"
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
enableIsolatedWorkspaces ? "bg-success" : "bg-muted",
)}
onClick={() => toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
enableIsolatedWorkspaces ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Auto-Restart Dev Server When Idle</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
In `pnpm dev:once`, wait for all queued and running local agent runs to finish, then restart the server
automatically when backend changes or migrations make the current boot stale.
</p>
</div>
<button
type="button"
data-slot="toggle"
aria-label="Toggle guarded dev-server auto-restart"
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
autoRestartDevServerWhenIdle ? "bg-success" : "bg-muted",
)}
onClick={() =>
toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })
}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
autoRestartDevServerWhenIdle ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
</section>
</div>
);
}

View file

@ -1,251 +1,47 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { PatchInstanceGeneralSettings } from "@paperclipai/shared";
import { SlidersHorizontal } from "lucide-react";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { useEffect } from "react";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useTheme, THEME_META, ORDERED_THEMES, type Theme } from "../context/ThemeContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
import { WorkspaceSection } from "../components/settings/WorkspaceSection";
import { LocalAISection } from "../components/settings/LocalAISection";
import { CloudProvidersSection } from "../components/settings/CloudProvidersSection";
import { SkillsSection } from "../components/settings/SkillsSection";
import { RoutinesSection } from "../components/settings/RoutinesSection";
import { TelegramSection } from "../components/settings/TelegramSection";
import { AboutSection } from "../components/settings/AboutSection";
import { DangerZoneSection } from "../components/settings/DangerZoneSection";
/**
* Nexus consolidated Settings page.
*
* Replaces the nested Paperclip instance-settings tree with a single-column
* scroll page that renders eight section cards per
* docs/specs/2026-04-11-nexus-layout-overhaul.md §8.1.
*
* The `/instance/settings/general` route is preserved so existing bookmarks
* and the icon-rail Settings destination keep resolving. All previous
* nested sub-routes redirect here.
*/
export function InstanceGeneralSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
const { theme, setTheme } = useTheme();
useEffect(() => {
setBreadcrumbs([
{ label: "Instance Settings" },
{ label: "General" },
]);
setBreadcrumbs([{ label: "Settings" }]);
}, [setBreadcrumbs]);
const generalQuery = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
});
const updateGeneralMutation = useMutation({
mutationFn: instanceSettingsApi.updateGeneral,
onSuccess: async () => {
setActionError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.generalSettings });
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to update general settings.");
},
});
if (generalQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading general settings...</div>;
}
if (generalQuery.error) {
return (
<div className="text-sm text-destructive">
{generalQuery.error instanceof Error
? generalQuery.error.message
: "Failed to load general settings."}
</div>
);
}
const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true;
const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true;
const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt";
return (
<div className="max-w-4xl space-y-6">
<div className="space-y-2">
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">General</h1>
</div>
<p className="text-sm text-muted-foreground">
Configure instance-wide defaults that affect how operator-visible logs are displayed.
</p>
<div className="mx-auto w-full max-w-[960px] px-6 py-8">
<h1 className="mb-6 text-[14px] font-semibold uppercase tracking-[0.1em] text-primary">
Settings
</h1>
<div className="space-y-6">
<WorkspaceSection />
<LocalAISection />
<CloudProvidersSection />
<SkillsSection />
<RoutinesSection />
<TelegramSection />
<AboutSection />
<DangerZoneSection />
</div>
{actionError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
)}
<section className="rounded-xl border border-border bg-card p-5">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Theme</h2>
<p className="text-sm text-muted-foreground">Choose the visual theme for Nexus.</p>
</div>
<div className="mt-4 flex flex-wrap gap-3">
{ORDERED_THEMES.map((id) => {
const meta = THEME_META[id];
return (
<button
key={id}
type="button"
onClick={() => setTheme(id)}
className={cn(
"flex items-center gap-2.5 rounded-lg border px-3 py-2 text-sm transition-colors",
theme === id
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-foreground hover:border-ring",
)}
>
<span
className="h-4 w-4 rounded-full border border-border/50 shrink-0"
style={{ backgroundColor: meta.primary }}
/>
{meta.label}
</button>
);
})}
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Censor username in logs</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Hide the username segment in home-directory paths and similar operator-visible log output. Standalone
username mentions outside of paths are not yet masked in the live transcript view. This is off by
default.
</p>
</div>
<button
type="button"
data-slot="toggle"
aria-label="Toggle username log censoring"
disabled={updateGeneralMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
censorUsernameInLogs ? "bg-success" : "bg-muted",
)}
onClick={() =>
updateGeneralMutation.mutate({
censorUsernameInLogs: !censorUsernameInLogs,
})
}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
censorUsernameInLogs ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Enable app keyboard shortcuts, including inbox navigation and global shortcuts like creating issues or
toggling panels. This is off by default.
</p>
</div>
<button
type="button"
data-slot="toggle"
aria-label="Toggle keyboard shortcuts"
disabled={updateGeneralMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
keyboardShortcuts ? "bg-success" : "bg-muted",
)}
onClick={() => updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
keyboardShortcuts ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="space-y-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">AI feedback sharing</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Control whether thumbs up and thumbs down votes can send the voted AI output to
Paperclip Labs. Votes are always saved locally.
</p>
{FEEDBACK_TERMS_URL ? (
<a
href={FEEDBACK_TERMS_URL}
target="_blank"
rel="noreferrer"
className="inline-flex text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
Read our terms of service
</a>
) : null}
</div>
{feedbackDataSharingPreference === "prompt" ? (
<div className="rounded-lg border border-border/70 bg-accent/20 px-3 py-2 text-sm text-muted-foreground">
No default is saved yet. The next thumbs up or thumbs down choice will ask once and
then save the answer here.
</div>
) : null}
<div className="flex flex-wrap gap-2">
{[
{
value: "allowed",
label: "Always allow",
description: "Share voted AI outputs automatically.",
},
{
value: "not_allowed",
label: "Don't allow",
description: "Keep voted AI outputs local only.",
},
].map((option) => {
const active = feedbackDataSharingPreference === option.value;
return (
<button
key={option.value}
type="button"
disabled={updateGeneralMutation.isPending}
className={cn(
"rounded-lg border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
active
? "border-foreground bg-accent text-foreground"
: "border-border bg-background hover:bg-accent/50",
)}
onClick={() =>
updateGeneralMutation.mutate({
feedbackDataSharingPreference: option.value as
| "allowed"
| "not_allowed",
})
}
>
<div className="text-sm font-medium">{option.label}</div>
<div className="text-xs text-muted-foreground">
{option.description}
</div>
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
To retest the first-use prompt in local dev, remove the{" "}
<code>feedbackDataSharingPreference</code> key from the{" "}
<code>instance_settings.general</code> JSON row for this instance, or set it back to{" "}
<code>"prompt"</code>. Unset and <code>"prompt"</code> both mean no default has been
chosen yet.
</p>
</div>
</section>
</div>
);
}

View file

@ -1,284 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { VOCAB } from "@paperclipai/branding";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Clock3, ExternalLink, Settings } from "lucide-react";
import type { InstanceSchedulerHeartbeatAgent } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { heartbeatsApi } from "../api/heartbeats";
import { agentsApi } from "../api/agents";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { EmptyState } from "../components/EmptyState";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { queryKeys } from "../lib/queryKeys";
import { formatDateTime, relativeTime } from "../lib/utils";
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function humanize(value: string) {
return value.replaceAll("_", " ");
}
function buildAgentHref(agent: InstanceSchedulerHeartbeatAgent) {
return `/${agent.companyIssuePrefix}/agents/${encodeURIComponent(agent.agentUrlKey)}`;
}
export function InstanceSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([
{ label: "Instance Settings" },
{ label: "Heartbeats" },
]);
}, [setBreadcrumbs]);
const heartbeatsQuery = useQuery({
queryKey: queryKeys.instance.schedulerHeartbeats,
queryFn: () => heartbeatsApi.listInstanceSchedulerAgents(),
refetchInterval: 15_000,
});
const toggleMutation = useMutation({
mutationFn: async (agentRow: InstanceSchedulerHeartbeatAgent) => {
const agent = await agentsApi.get(agentRow.id, agentRow.companyId);
const runtimeConfig = asRecord(agent.runtimeConfig) ?? {};
const heartbeat = asRecord(runtimeConfig.heartbeat) ?? {};
return agentsApi.update(
agentRow.id,
{
runtimeConfig: {
...runtimeConfig,
heartbeat: {
...heartbeat,
enabled: !agentRow.heartbeatEnabled,
},
},
},
agentRow.companyId,
);
},
onSuccess: async (_, agentRow) => {
setActionError(null);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.instance.schedulerHeartbeats }),
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(agentRow.companyId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentRow.id) }),
]);
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to update heartbeat.");
},
});
const disableAllMutation = useMutation({
mutationFn: async (agentRows: InstanceSchedulerHeartbeatAgent[]) => {
const enabled = agentRows.filter((a) => a.heartbeatEnabled);
if (enabled.length === 0) return enabled;
const results = await Promise.allSettled(
enabled.map(async (agentRow) => {
const agent = await agentsApi.get(agentRow.id, agentRow.companyId);
const runtimeConfig = asRecord(agent.runtimeConfig) ?? {};
const heartbeat = asRecord(runtimeConfig.heartbeat) ?? {};
await agentsApi.update(
agentRow.id,
{
runtimeConfig: {
...runtimeConfig,
heartbeat: { ...heartbeat, enabled: false },
},
},
agentRow.companyId,
);
}),
);
const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
if (failures.length > 0) {
const firstError = failures[0]?.reason;
const detail = firstError instanceof Error ? firstError.message : "Unknown error";
throw new Error(
failures.length === 1
? `Failed to disable 1 timer heartbeat: ${detail}`
: `Failed to disable ${failures.length} of ${enabled.length} timer heartbeats. First error: ${detail}`,
);
}
return enabled;
},
onSuccess: async (updatedRows) => {
setActionError(null);
const companies = new Set(updatedRows.map((row) => row.companyId));
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.instance.schedulerHeartbeats }),
...Array.from(companies, (companyId) =>
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }),
),
...updatedRows.map((row) =>
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(row.id) }),
),
]);
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to disable all heartbeats.");
},
});
const agents = heartbeatsQuery.data ?? [];
const activeCount = agents.filter((agent) => agent.schedulerActive).length;
const disabledCount = agents.length - activeCount;
const enabledCount = agents.filter((agent) => agent.heartbeatEnabled).length;
const anyEnabled = enabledCount > 0;
const grouped = useMemo(() => {
const map = new Map<string, { companyName: string; agents: InstanceSchedulerHeartbeatAgent[] }>();
for (const agent of agents) {
let group = map.get(agent.companyId);
if (!group) {
group = { companyName: agent.companyName, agents: [] };
map.set(agent.companyId, group);
}
group.agents.push(agent);
}
return [...map.values()];
}, [agents]);
if (heartbeatsQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading scheduler heartbeats...</div>;
}
if (heartbeatsQuery.error) {
return (
<div className="text-sm text-destructive">
{heartbeatsQuery.error instanceof Error
? heartbeatsQuery.error.message
: "Failed to load scheduler heartbeats."}
</div>
);
}
return (
<div className="max-w-5xl space-y-6">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Scheduler Heartbeats</h1>
</div>
<p className="text-sm text-muted-foreground">
{`Agents with a timer heartbeat enabled across all of your ${VOCAB.companies.toLowerCase()}.`}
</p>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span><span className="font-semibold text-foreground">{activeCount}</span> active</span>
<span><span className="font-semibold text-foreground">{disabledCount}</span> disabled</span>
<span><span className="font-semibold text-foreground">{grouped.length}</span> {grouped.length === 1 ? VOCAB.company.toLowerCase() : VOCAB.companies.toLowerCase()}</span>
{anyEnabled && (
<Button
variant="destructive"
size="sm"
className="ml-auto h-7 text-xs"
disabled={disableAllMutation.isPending}
onClick={() => {
const noun = enabledCount === 1 ? "agent" : "agents";
if (!window.confirm(`Disable timer heartbeats for all ${enabledCount} enabled ${noun}?`)) {
return;
}
disableAllMutation.mutate(agents);
}}
>
{disableAllMutation.isPending ? "Disabling..." : "Disable All"}
</Button>
)}
</div>
{actionError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
)}
{agents.length === 0 ? (
<EmptyState
icon={Clock3}
message="No scheduler heartbeats match the current criteria."
/>
) : (
<div className="space-y-4">
{grouped.map((group) => (
<Card key={group.companyName}>
<CardContent className="p-0">
<div className="border-b px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{group.companyName}
</div>
<div className="divide-y">
{group.agents.map((agent) => {
const saving = toggleMutation.isPending && toggleMutation.variables?.id === agent.id;
return (
<div
key={agent.id}
className="flex items-center gap-3 px-3 py-2 text-sm"
>
<Badge
variant={agent.schedulerActive ? "default" : "outline"}
className="shrink-0 text-[10px] px-1.5 py-0"
>
{agent.schedulerActive ? "On" : "Off"}
</Badge>
<Link
to={buildAgentHref(agent)}
className="font-medium truncate hover:underline"
>
{agent.agentName}
</Link>
<span className="hidden sm:inline text-muted-foreground truncate">
{humanize(agent.title ?? agent.role)}
</span>
<span className="text-muted-foreground tabular-nums shrink-0">
{agent.intervalSec}s
</span>
<span
className="hidden md:inline text-muted-foreground truncate"
title={agent.lastHeartbeatAt ? formatDateTime(agent.lastHeartbeatAt) : undefined}
>
{agent.lastHeartbeatAt
? relativeTime(agent.lastHeartbeatAt)
: "never"}
</span>
<span className="ml-auto flex items-center gap-1.5 shrink-0">
<Link
to={buildAgentHref(agent)}
className="text-muted-foreground hover:text-foreground"
title="Full agent config"
>
<ExternalLink className="h-3.5 w-3.5" />
</Link>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
disabled={saving}
onClick={() => toggleMutation.mutate(agent)}
>
{saving ? "..." : agent.heartbeatEnabled ? "Disable Timer Heartbeat" : "Enable Timer Heartbeat"}
</Button>
</span>
</div>
);
})}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}