diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index d94f622b..36c30ce6 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -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() {
}>
} />
} />
- } />
- } />
+ {/* Phase 13: nested settings sub-routes collapse into /general. */}
+ } />
+ } />
+ {/* Plugins remain top-level pages owned by the plugin system, not settings sub-pages. */}
} />
} />
diff --git a/ui/src/components/InstanceSidebar.tsx b/ui/src/components/InstanceSidebar.tsx
deleted file mode 100644
index dbd8381b..00000000
--- a/ui/src/components/InstanceSidebar.tsx
+++ /dev/null
@@ -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 (
-
- );
-}
diff --git a/ui/src/components/settings/CloudProvidersSection.tsx b/ui/src/components/settings/CloudProvidersSection.tsx
index 09b232a7..0b5a5f5f 100644
--- a/ui/src/components/settings/CloudProvidersSection.tsx
+++ b/ui/src/components/settings/CloudProvidersSection.tsx
@@ -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}.`,
});
},
});
diff --git a/ui/src/components/settings/RoutinesSection.tsx b/ui/src/components/settings/RoutinesSection.tsx
index b85770db..13a747b4 100644
--- a/ui/src/components/settings/RoutinesSection.tsx
+++ b/ui/src/components/settings/RoutinesSection.tsx
@@ -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 (
{
pushToast({
- type: "error",
- message:
+ tone: "error",
+ title:
error instanceof Error ? error.message : "Failed to save Telegram token.",
});
},
diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx
deleted file mode 100644
index 78827a0b..00000000
--- a/ui/src/pages/InstanceExperimentalSettings.tsx
+++ /dev/null
@@ -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(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 Loading experimental settings...
;
- }
-
- if (experimentalQuery.error) {
- return (
-
- {experimentalQuery.error instanceof Error
- ? experimentalQuery.error.message
- : "Failed to load experimental settings."}
-
- );
- }
-
- const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
- const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
-
- return (
-
-
-
-
-
Experimental
-
-
- Opt into features that are still being evaluated before they become default behavior.
-
-
-
- {actionError && (
-
- {actionError}
-
- )}
-
-
-
-
-
Enable Isolated Workspaces
-
- Show execution workspace controls in project configuration and allow isolated workspace behavior for new
- and existing issue runs.
-
-
-
toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })}
- >
-
-
-
-
-
-
-
-
-
Auto-Restart Dev Server When Idle
-
- 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.
-
-
-
- toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })
- }
- >
-
-
-
-
-
- );
-}
diff --git a/ui/src/pages/InstanceGeneralSettings.tsx b/ui/src/pages/InstanceGeneralSettings.tsx
index cbeff1ff..73b64043 100644
--- a/ui/src/pages/InstanceGeneralSettings.tsx
+++ b/ui/src/pages/InstanceGeneralSettings.tsx
@@ -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(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 Loading general settings...
;
- }
-
- if (generalQuery.error) {
- return (
-
- {generalQuery.error instanceof Error
- ? generalQuery.error.message
- : "Failed to load general settings."}
-
- );
- }
-
- const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true;
- const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true;
- const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt";
-
return (
-
-
-
-
-
General
-
-
- Configure instance-wide defaults that affect how operator-visible logs are displayed.
-
+
+
+ Settings
+
+
-
- {actionError && (
-
- {actionError}
-
- )}
-
-
-
-
Theme
-
Choose the visual theme for Nexus.
-
-
- {ORDERED_THEMES.map((id) => {
- const meta = THEME_META[id];
- return (
- 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",
- )}
- >
-
- {meta.label}
-
- );
- })}
-
-
-
-
-
-
-
Censor username in logs
-
- 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.
-
-
-
- updateGeneralMutation.mutate({
- censorUsernameInLogs: !censorUsernameInLogs,
- })
- }
- >
-
-
-
-
-
-
-
-
-
Keyboard shortcuts
-
- Enable app keyboard shortcuts, including inbox navigation and global shortcuts like creating issues or
- toggling panels. This is off by default.
-
-
-
updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })}
- >
-
-
-
-
-
-
-
-
-
AI feedback sharing
-
- Control whether thumbs up and thumbs down votes can send the voted AI output to
- Paperclip Labs. Votes are always saved locally.
-
- {FEEDBACK_TERMS_URL ? (
-
- Read our terms of service
-
- ) : null}
-
- {feedbackDataSharingPreference === "prompt" ? (
-
- No default is saved yet. The next thumbs up or thumbs down choice will ask once and
- then save the answer here.
-
- ) : null}
-
- {[
- {
- 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 (
-
- updateGeneralMutation.mutate({
- feedbackDataSharingPreference: option.value as
- | "allowed"
- | "not_allowed",
- })
- }
- >
- {option.label}
-
- {option.description}
-
-
- );
- })}
-
-
- To retest the first-use prompt in local dev, remove the{" "}
- feedbackDataSharingPreference key from the{" "}
- instance_settings.general JSON row for this instance, or set it back to{" "}
- "prompt". Unset and "prompt" both mean no default has been
- chosen yet.
-
-
-
);
}
diff --git a/ui/src/pages/InstanceSettings.tsx b/ui/src/pages/InstanceSettings.tsx
deleted file mode 100644
index 51db299a..00000000
--- a/ui/src/pages/InstanceSettings.tsx
+++ /dev/null
@@ -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
| null {
- if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
- return value as Record;
-}
-
-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(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();
- 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 Loading scheduler heartbeats...
;
- }
-
- if (heartbeatsQuery.error) {
- return (
-
- {heartbeatsQuery.error instanceof Error
- ? heartbeatsQuery.error.message
- : "Failed to load scheduler heartbeats."}
-
- );
- }
-
- return (
-
-
-
-
-
Scheduler Heartbeats
-
-
- {`Agents with a timer heartbeat enabled across all of your ${VOCAB.companies.toLowerCase()}.`}
-
-
-
-
- {activeCount} active
- {disabledCount} disabled
- {grouped.length} {grouped.length === 1 ? VOCAB.company.toLowerCase() : VOCAB.companies.toLowerCase()}
- {anyEnabled && (
- {
- 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"}
-
- )}
-
-
- {actionError && (
-
- {actionError}
-
- )}
-
- {agents.length === 0 ? (
-
- ) : (
-
- {grouped.map((group) => (
-
-
-
- {group.companyName}
-
-
- {group.agents.map((agent) => {
- const saving = toggleMutation.isPending && toggleMutation.variables?.id === agent.id;
- return (
-
-
- {agent.schedulerActive ? "On" : "Off"}
-
-
- {agent.agentName}
-
-
- {humanize(agent.title ?? agent.role)}
-
-
- {agent.intervalSec}s
-
-
- {agent.lastHeartbeatAt
- ? relativeTime(agent.lastHeartbeatAt)
- : "never"}
-
-
-
-
-
- toggleMutation.mutate(agent)}
- >
- {saving ? "..." : agent.heartbeatEnabled ? "Disable Timer Heartbeat" : "Enable Timer Heartbeat"}
-
-
-
- );
- })}
-
-
-
- ))}
-
- )}
-
- );
-}