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