Files
paperclip/ui/src/pages/InstanceSettings.tsx
T
Dotta 76c88e5855 [codex] Move instance settings under company settings (#7680)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Operators manage both company-scoped configuration and
instance-level runtime/admin settings from the board UI
> - Instance settings previously lived as their own top-level sidebar
area, separate from the company settings context operators already use
> - That split made settings navigation feel heavier and made instance
configuration less discoverable from the settings tab
> - This pull request moves instance settings under company settings
while preserving the existing instance settings routes and plugin/admin
surfaces
> - The benefit is a smaller primary sidebar and a more coherent
settings hierarchy for operators

## Linked Issues or Issue Description

- Refs #338
- Internal: PAP-10491, PAP-10538

## What Changed

- Moved instance settings navigation under the company settings area.
- Added route helpers and sidebar entries for nested instance settings
paths.
- Updated plugin/admin settings routes to use the company settings
instance scope.
- Preserved legacy instance-settings bookmarks through compatibility
redirects that keep the active company prefix.
- Updated focused UI and plugin tests for the new navigation shape.
- Stabilized the process-loss retry test that was failing the serialized
server shard in CI.
- Rebased the branch onto current `paperclipai/paperclip` `master` and
pushed the current head.

## Verification

- `pnpm exec vitest run
ui/src/components/CompanySettingsSidebar.test.tsx
ui/src/components/access/CompanySettingsNav.test.tsx
ui/src/lib/instance-settings.test.ts
ui/src/components/InstanceSidebar.test.tsx
ui/src/components/Layout.test.tsx
ui/src/components/SidebarAccountMenu.test.tsx
ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts
packages/shared/src/validators/plugin.test.ts`
- `pnpm exec vitest run ui/src/lib/instance-settings.test.ts
ui/src/components/CompanySettingsSidebar.test.tsx
ui/src/components/access/CompanySettingsNav.test.tsx
ui/src/components/Layout.test.tsx ui/src/plugins/bridge.test.ts`
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts -t "queues
exactly one retry when the recorded local pid is dead"`
- `pnpm test:run:serialized -- --shard-index 0 --shard-count 4`
- GitHub PR checks are green on head
`fe7b0955169dcae55cbe10889c1876a70ab0b80c`, including `verify`, `General
tests (server)`, all serialized server shards, build, e2e, policy,
security checks, and Greptile.
- Confirmed the PR diff does not include `pnpm-lock.yaml` or
`.github/workflows` changes.

## Risks

- Medium UI/navigation risk: instance settings links are intentionally
moving under company settings, so stale external bookmarks to legacy
paths rely on the compatibility routing in this branch.
- Low test-only risk from the CI stabilization commit: it makes the
recovery assertion select the actual retry run by `retryOfRunId` instead
of whichever non-original run appears first.
- No database migrations.
- No dependency lockfile or workflow changes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex coding agent based on GPT-5, with shell/tool execution in
a local repository worktree. Exact context window was not exposed by the
runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-07 17:23:53 -05:00

285 lines
11 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
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: "Settings", href: "/company/settings" },
{ label: "Instance settings", href: "/company/settings/instance/general" },
{ 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 companies.
</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 ? "company" : "companies"}</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>
);
}