mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-07 16:11:46 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Instance settings collect deployment-wide controls; one of them was the Heartbeats page, an instance-wide list of scheduler heartbeat agents with enable/disable toggles > - The same controls live on each agent's own configuration surface, so the standalone list duplicates them, and its framing no longer matches how heartbeat agents are managed > - Keeping a settings view that no longer makes sense costs every deployment navigation noise and maintenance > - This pull request removes the page, its route, its navigation entries, and its hidden-settings key for all deployments > - The benefit is a smaller, coherent settings surface, with operator hidden-settings lists that still mention the retired key continuing to work unchanged ## Linked Issues or Issue Description No public issue exists; describing the issue inline per the enhancement template: **What existing behavior does this improve?** The instance settings surface — specifically the Settings → Heartbeats page, which listed scheduler heartbeat agents instance-wide with enable/disable toggles. The view no longer makes sense as a standalone settings page: the same controls are available on each agent's configuration surface, and the instance-wide list framing does not match how heartbeat agents are managed. **Subsystem affected** Cross-cutting: `ui/` (page, route, navigation), `packages/shared` (settings-visibility registry), docs. **Current behavior** The page renders at `/company/settings/instance/heartbeats`, appears in the settings sidebar and tab bar, and is hideable by hosting operators via the `instance.heartbeats` key of `PAPERCLIP_HIDDEN_SETTINGS`. **Proposed behavior** The page, route, and navigation entries are removed for every deployment. The `instance.heartbeats` registry key is retired; operator lists that still send it are logged and ignored, so mixed-version fleets keep working. Remembered settings paths pointing at the old page remap to the settings root. Heartbeat APIs are unchanged. **Reason and benefit** A smaller, coherent settings surface with no duplicated controls; less navigation noise and maintenance for every deployment. **Breaking changes** None functional. Bookmarks and remembered paths to the removed page land on the settings root; `PAPERCLIP_HIDDEN_SETTINGS` lists that still include `instance.heartbeats` log a warning and are otherwise honored unchanged. ## What Changed - Deleted `ui/src/pages/InstanceSettings.tsx` (the Heartbeats view) and its route in `ui/src/App.tsx`. - Removed the sidebar entry (`CompanySettingsSidebar`) and tab-bar item (`CompanySettingsNav`). - Removed `"/heartbeats"` from the remembered-settings-path allowlist; remembered heartbeats paths now remap to the settings root. - Retired the `instance.heartbeats` key from the shared settings-visibility registry and the environment-variables doc; documented that retired keys are ignored with a warning. - Dropped the now-unused UI client wrapper for the instance scheduler-agent list (`heartbeatsApi.listInstanceSchedulerAgents`); the server endpoint stays. - Removed the unused `schedulerHeartbeats` query key. ## Verification - `npx vitest run packages/shared/src/settings-visibility.test.ts ui/src/lib/instance-settings.test.ts ui/src/components/CompanySettingsSidebar.test.tsx ui/src/components/access/CompanySettingsNav.test.tsx` — 24 tests passing. - Full `ui` vitest suite: 4426 tests, 4 failures — all in files this PR does not touch; 3 were load-induced timeouts that pass on rerun, and `OnboardingWizard.test.tsx` "renders instead of throwing when the browser denies storage access" fails identically on a clean master checkout (pre-existing). - `pnpm --filter @paperclipai/ui typecheck` and `pnpm --filter @paperclipai/shared typecheck` — clean. - Merged `master` to clear a conflict (see below) and re-ran the four focused suites (24 passing), `ui/src/App.test.tsx` and `ui/src/plugins/bridge.test.ts` (22 passing), and both typechecks — all clean. Full CI is green on the merge commit. ## Merge With master `master` gained the `company` → `organization` copy pass (#12243), which reworded strings inside `ui/src/pages/InstanceSettings.tsx` — the page this branch deletes — producing a modify/delete conflict. Resolved by keeping the deletion: the page is going away, so the rewording of its copy has nothing to apply to. Every other file merged cleanly, and `master`'s rewording in `App.tsx`, `App.test.tsx`, and `CompanySettingsSidebar.tsx` sits away from this branch's structural removals, so both changes survive. The net diff against `master` is unchanged from the pre-merge review: the same 13 files, 23 insertions, 330 deletions. ## Risks - Low. Pure removal of a UI surface; heartbeat data and APIs are untouched. Operators still listing `instance.heartbeats` in `PAPERCLIP_HIDDEN_SETTINGS` get a warning log and otherwise unchanged behavior (covered by the registry's unknown-key handling). Bookmarks and remembered paths to the old page land on the settings root. ## Model Used Claude (Anthropic), model id `claude-fable-5`, extended thinking, agentic tool use via Claude Code. ## 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 not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [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
186 lines
6.3 KiB
TypeScript
186 lines
6.3 KiB
TypeScript
import { INSTANCE_FEATURE_KEYS, type InstanceFeatureKey } from "./feature-catalog.js";
|
|
|
|
/**
|
|
* Operator-configurable settings visibility.
|
|
*
|
|
* A hosting operator (a managed cloud, an internal shared server) can hide
|
|
* settings surfaces that do not apply to their deployment by setting the
|
|
* `PAPERCLIP_HIDDEN_SETTINGS` environment variable to a comma-separated
|
|
* list of keys from this registry. Hiding a surface removes it from the UI
|
|
* (nav, routes, page sections). Surfaces backed by instance-level mutation
|
|
* routes are also floored with a 403 carrying
|
|
* `SETTINGS_OPERATOR_MANAGED_ERROR_CODE`: the Access, Plugins, and Adapters
|
|
* pages, every field-backed General section, every experimental toggle
|
|
* (individually or via the whole Experimental page), and the company Import
|
|
* page (whose whole route surface is floored). The other company pages are
|
|
* UI-visibility keys only: their APIs (memberships, invites, secrets,
|
|
* exports) stay live for agents and integrations.
|
|
*
|
|
* Nothing is hidden by default: with the variable unset, UI and API behave
|
|
* exactly as before this mechanism existed.
|
|
*
|
|
* Unknown keys are ignored (with a server-side warning) rather than rejected,
|
|
* so an operator may roll one list across a fleet of mixed app versions: an
|
|
* image that predates a key simply keeps that surface visible instead of
|
|
* refusing to boot.
|
|
*/
|
|
|
|
/**
|
|
* Instance settings pages that can be hidden (nav entry + route). The General
|
|
* page is deliberately not hideable: it is the settings root and the redirect
|
|
* target for hidden pages. Individual General sections are hideable below.
|
|
*/
|
|
export const HIDEABLE_INSTANCE_PAGES = [
|
|
"instance.profile",
|
|
"instance.environments",
|
|
"instance.access",
|
|
"instance.experimental",
|
|
"instance.plugins",
|
|
"instance.adapters",
|
|
] as const;
|
|
|
|
export type HideableInstancePage = (typeof HIDEABLE_INSTANCE_PAGES)[number];
|
|
|
|
/**
|
|
* Company-level settings pages that can be hidden (nav entry + tab + route).
|
|
* The company General page is deliberately not hideable: it is the settings
|
|
* root and the redirect target for hidden pages. `company.import` also floors
|
|
* the import API routes; the rest only hide UI surfaces.
|
|
*/
|
|
export const HIDEABLE_COMPANY_PAGES = [
|
|
"company.members",
|
|
"company.invites",
|
|
"company.secrets",
|
|
"company.export",
|
|
"company.import",
|
|
] as const;
|
|
|
|
export type HideableCompanyPage = (typeof HIDEABLE_COMPANY_PAGES)[number];
|
|
|
|
/**
|
|
* Sub-surfaces of company settings pages that can be hidden individually.
|
|
* UI-visibility keys only: the backing APIs stay live for agents and
|
|
* integrations. Hiding the whole page (`company.secrets`) already removes
|
|
* everything inside it; these keys hide one tab while the page stays up.
|
|
*/
|
|
export const HIDEABLE_COMPANY_SECTIONS = [
|
|
"company.secrets.vaults",
|
|
"company.secrets.proposals",
|
|
] as const;
|
|
|
|
export type HideableCompanySection = (typeof HIDEABLE_COMPANY_SECTIONS)[number];
|
|
|
|
/**
|
|
* Sections of Instance → General that can be hidden. Field-backed sections
|
|
* (their suffix names a general-settings field) also floor writes to that
|
|
* field; `deploymentStatus` and `signOut` are read-only UI with no field.
|
|
*/
|
|
export const HIDEABLE_GENERAL_SECTIONS = [
|
|
"instance.general.deploymentStatus",
|
|
"instance.general.censorUsernameInLogs",
|
|
"instance.general.keyboardShortcuts",
|
|
"instance.general.backupRetention",
|
|
"instance.general.feedbackDataSharingPreference",
|
|
"instance.general.signOut",
|
|
] as const;
|
|
|
|
export type HideableGeneralSection = (typeof HIDEABLE_GENERAL_SECTIONS)[number];
|
|
|
|
/** General sections that are informational UI only, with no settings field. */
|
|
export const UI_ONLY_GENERAL_SECTIONS = [
|
|
"instance.general.deploymentStatus",
|
|
"instance.general.signOut",
|
|
] as const satisfies readonly HideableGeneralSection[];
|
|
|
|
export type HideableExperimentalSetting = `instance.experimental.${InstanceFeatureKey}`;
|
|
|
|
/** The visibility key for an experimental toggle; every boolean flag is hideable. */
|
|
export function experimentalSettingKey(key: InstanceFeatureKey): HideableExperimentalSetting {
|
|
return `instance.experimental.${key}`;
|
|
}
|
|
|
|
export type HideableSettingKey =
|
|
| HideableInstancePage
|
|
| HideableCompanyPage
|
|
| HideableCompanySection
|
|
| HideableGeneralSection
|
|
| HideableExperimentalSetting;
|
|
|
|
/** Every key `PAPERCLIP_HIDDEN_SETTINGS` accepts. */
|
|
export const HIDEABLE_SETTING_KEYS: readonly HideableSettingKey[] = [
|
|
...HIDEABLE_INSTANCE_PAGES,
|
|
...HIDEABLE_COMPANY_PAGES,
|
|
...HIDEABLE_COMPANY_SECTIONS,
|
|
...HIDEABLE_GENERAL_SECTIONS,
|
|
...INSTANCE_FEATURE_KEYS.map(experimentalSettingKey),
|
|
];
|
|
|
|
/** Stable 403 code for writes to operator-hidden settings. */
|
|
export const SETTINGS_OPERATOR_MANAGED_ERROR_CODE = "settings_operator_managed";
|
|
|
|
export interface ParsedHiddenSettings {
|
|
/** Recognized keys, deduplicated, in input order. */
|
|
hidden: HideableSettingKey[];
|
|
/** Unrecognized entries, for the caller to warn about. */
|
|
unknown: string[];
|
|
}
|
|
|
|
/** Parse a `PAPERCLIP_HIDDEN_SETTINGS`-style comma-separated list. */
|
|
export function parseHiddenSettingsList(raw: string | undefined): ParsedHiddenSettings {
|
|
const hidden: HideableSettingKey[] = [];
|
|
const unknown: string[] = [];
|
|
if (!raw) return { hidden, unknown };
|
|
const known = new Set<string>(HIDEABLE_SETTING_KEYS);
|
|
const seen = new Set<string>();
|
|
for (const part of raw.split(",")) {
|
|
const key = part.trim();
|
|
if (!key || seen.has(key)) continue;
|
|
seen.add(key);
|
|
if (known.has(key)) {
|
|
hidden.push(key as HideableSettingKey);
|
|
} else {
|
|
unknown.push(key);
|
|
}
|
|
}
|
|
return { hidden, unknown };
|
|
}
|
|
|
|
export function hidesInstancePage(
|
|
hidden: ReadonlySet<string>,
|
|
page: HideableInstancePage,
|
|
): boolean {
|
|
return hidden.has(page);
|
|
}
|
|
|
|
export function hidesCompanyPage(
|
|
hidden: ReadonlySet<string>,
|
|
page: HideableCompanyPage,
|
|
): boolean {
|
|
return hidden.has(page);
|
|
}
|
|
|
|
export function hidesCompanySection(
|
|
hidden: ReadonlySet<string>,
|
|
section: HideableCompanySection,
|
|
): boolean {
|
|
return hidden.has(section);
|
|
}
|
|
|
|
export function hidesGeneralSection(
|
|
hidden: ReadonlySet<string>,
|
|
section: HideableGeneralSection,
|
|
): boolean {
|
|
return hidden.has(section);
|
|
}
|
|
|
|
/**
|
|
* Whether a toggle is hidden, either individually or because the whole
|
|
* Experimental page is hidden.
|
|
*/
|
|
export function hidesExperimentalSetting(
|
|
hidden: ReadonlySet<string>,
|
|
key: InstanceFeatureKey,
|
|
): boolean {
|
|
return hidden.has("instance.experimental") || hidden.has(experimentalSettingKey(key));
|
|
}
|