From 1a0d611cb12a05f2501d13dd333a0a42b3e12cb2 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 13:28:40 +0000 Subject: [PATCH] refactor(nexus): consolidate Settings into single-column scroll page (phase 13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ui/src/App.tsx | 8 +- ui/src/components/InstanceSidebar.tsx | 53 ---- .../settings/CloudProvidersSection.tsx | 6 +- .../components/settings/RoutinesSection.tsx | 6 +- .../components/settings/TelegramSection.tsx | 8 +- ui/src/pages/InstanceExperimentalSettings.tsx | 139 --------- ui/src/pages/InstanceGeneralSettings.tsx | 272 +++-------------- ui/src/pages/InstanceSettings.tsx | 284 ------------------ 8 files changed, 46 insertions(+), 730 deletions(-) delete mode 100644 ui/src/components/InstanceSidebar.tsx delete mode 100644 ui/src/pages/InstanceExperimentalSettings.tsx delete mode 100644 ui/src/pages/InstanceSettings.tsx 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. -

    -
    - -
    -
    - -
    -
    -
    -

    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. -

    -
    - -
    -
    -
    - ); -} 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 ( - - ); - })} -
    -
    - -
    -
    -
    -

    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. -

    -
    - -
    -
    - -
    -
    -
    -

    Keyboard shortcuts

    -

    - Enable app keyboard shortcuts, including inbox navigation and global shortcuts like creating issues or - toggling panels. This is off by default. -

    -
    - -
    -
    - -
    -
    -
    -

    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 ( - - ); - })} -
    -

    - 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 && ( - - )} -
    - - {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"} - - - - - - - -
    - ); - })} -
    -
    -
    - ))} -
    - )} -
    - ); -}