feat: server-side override pause/resume for builtin adapter types

Replace the client-side-only override store with a real server-side
toggle. When a developer pauses the external override, the server swaps
ALL adapter behavior back to the builtin — execute handler, model listing,
config schema, detection — not just the UI parser.

Server changes:
- registry.ts: builtinFallbacks map + pausedOverrides set + setOverridePaused()
- routes/adapters.ts: PATCH /api/adapters/:type/override endpoint + overridePaused in list

UI changes:
- adapters.ts: setOverridePaused API method + overridePaused on AdapterInfo
- AdapterManager: overrideMutation calls server, instant feedback via invalidate()
- use-disabled-adapters.ts: reads adapter.overridePaused from server response

Removed:
- disabled-overrides-store.ts: no longer needed (server is the source of truth)

Note: already-running agent sessions keep the adapter they started with.
Only new sessions use the swapped adapter.
This commit is contained in:
HenkDz 2026-04-04 13:17:21 +01:00
parent 4efe018a8f
commit b81d765d2e
6 changed files with 127 additions and 126 deletions

View file

@ -1,90 +0,0 @@
/**
* Client-side store for disabled external adapter overrides.
*
* When an external adapter overrides a builtin type, the user may want to
* pause the override (use the builtin parser) without hiding the type from
* menus entirely. This is separate from the server's per-type `disabled`
* flag which controls menu visibility.
*
* Persisted to localStorage so it survives page reloads.
*
* Implements the React external store pattern (subscribe/getSnapshot)
* so that components using useSyncExternalStore re-render on changes.
*/
const STORAGE_KEY = "paperclip:disabled-overrides";
let disabledOverrides = new Set<string>();
// ── React external store plumbing ────────────────────────────────────
/** Monotonically increasing version — changes on every mutation. */
let snapshotVersion = 0;
const listeners = new Set<() => void>();
/** Subscribe to store changes (for useSyncExternalStore). */
export function subscribeToOverrides(callback: () => void): () => void {
listeners.add(callback);
return () => listeners.delete(callback);
}
/**
* Return a value that changes whenever the store changes.
* React compares this with Object.is to decide whether to re-render.
*/
export function getOverridesSnapshot(): number {
return snapshotVersion;
}
function emitChange(): void {
snapshotVersion++;
for (const fn of listeners) fn();
}
// ── Public API ───────────────────────────────────────────────────────
/** Check if the external override for a builtin type is paused. */
export function isOverrideDisabled(type: string): boolean {
return disabledOverrides.has(type);
}
/** Pause or resume an external override. */
export function setOverrideDisabled(type: string, disabled: boolean): void {
if (disabled) {
disabledOverrides.add(type);
} else {
disabledOverrides.delete(type);
}
persist();
emitChange();
}
/** Get all types with paused overrides (sync read). */
export function getDisabledOverrides(): Set<string> {
return disabledOverrides;
}
// ── Persistence ──────────────────────────────────────────────────────
function persist(): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...disabledOverrides]));
} catch {
// localStorage unavailable — no-op
}
}
function hydrate(): void {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
disabledOverrides = new Set(JSON.parse(raw));
}
} catch {
// corrupt or unavailable — start empty
}
}
// Hydrate on module load
hydrate();

View file

@ -1,8 +1,7 @@
import { useEffect, useMemo, useSyncExternalStore } from "react";
import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { adaptersApi } from "@/api/adapters";
import { setDisabledAdapterTypes } from "@/adapters/disabled-store";
import { isOverrideDisabled, subscribeToOverrides, getOverridesSnapshot } from "@/adapters/disabled-overrides-store";
import { syncExternalAdapters } from "@/adapters/registry";
import { queryKeys } from "@/lib/queryKeys";
@ -24,10 +23,6 @@ export function useDisabledAdaptersSync(): Set<string> {
staleTime: 5 * 60 * 1000,
});
// Subscribe to the client-side override store so that
// syncExternalAdapters() re-runs when overrides are toggled.
useSyncExternalStore(subscribeToOverrides, getOverridesSnapshot);
// Eagerly register external adapter types in the UI registry so that
// consumers calling listUIAdapters() in the same render cycle see them.
// This is idempotent — already-registered types are skipped.
@ -39,7 +34,7 @@ export function useDisabledAdaptersSync(): Set<string> {
type: a.type,
label: a.label,
disabled: a.disabled,
overrideDisabled: a.overriddenBuiltin ? isOverrideDisabled(a.type) : undefined,
overrideDisabled: a.overridePaused,
})),
);
}

View file

@ -19,6 +19,8 @@ export interface AdapterInfo {
isLocalPath?: boolean;
/** True when an external plugin has replaced a built-in adapter of the same type. */
overriddenBuiltin?: boolean;
/** True when the external override for a builtin type is currently paused. */
overridePaused?: boolean;
}
export interface AdapterInstallResult {
@ -43,6 +45,10 @@ export const adaptersApi = {
setDisabled: (type: string, disabled: boolean) =>
api.patch<{ type: string; disabled: boolean; changed: boolean }>(`/adapters/${type}`, { disabled }),
/** Pause or resume an external override of a builtin type. */
setOverridePaused: (type: string, paused: boolean) =>
api.patch<{ type: string; paused: boolean; changed: boolean }>(`/adapters/${type}/override`, { paused }),
/** Reload an external adapter (bust server + client caches). */
reload: (type: string) =>
api.post<{ type: string; version?: string; reloaded: boolean }>(`/adapters/${type}/reload`, {}),

View file

@ -4,7 +4,7 @@
* Adapters are simpler than plugins: no workers, no events, no manifests.
* They just register a ServerAdapterModule that provides model discovery and execution.
*/
import { useEffect, useState, useSyncExternalStore } from "react";
import { useEffect, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Cpu, Plus, Power, Trash2, FolderOpen, Package, RefreshCw, Download } from "lucide-react";
import { useCompany } from "@/context/CompanyContext";
@ -32,7 +32,6 @@ import { cn } from "@/lib/utils";
import { ChoosePathButton } from "@/components/PathInstructionsModal";
import { invalidateDynamicParser } from "@/adapters/dynamic-loader";
import { invalidateConfigSchemaCache } from "@/adapters/schema-config-fields";
import { isOverrideDisabled, setOverrideDisabled, subscribeToOverrides, getOverridesSnapshot } from "@/adapters/disabled-overrides-store";
function AdapterRow({
adapter,
@ -258,11 +257,6 @@ export function AdapterManager() {
const queryClient = useQueryClient();
const { pushToast } = useToast();
// Subscribe to client-side override store so the component re-renders
// immediately when setOverrideDisabled() is called, even though the
// server query data hasn't changed.
useSyncExternalStore(subscribeToOverrides, getOverridesSnapshot);
const [installPackage, setInstallPackage] = useState("");
const [installVersion, setInstallVersion] = useState("");
const [isLocalPath, setIsLocalPath] = useState(false);
@ -309,9 +303,7 @@ export function AdapterManager() {
const removeMutation = useMutation({
mutationFn: (type: string) => adaptersApi.remove(type),
onSuccess: (_result, type) => {
// Clean up client-side override state when the external is removed.
setOverrideDisabled(type, false);
onSuccess: () => {
invalidate();
pushToast({ title: "Adapter removed", tone: "success" });
},
@ -331,6 +323,17 @@ export function AdapterManager() {
},
});
const overrideMutation = useMutation({
mutationFn: ({ type, paused }: { type: string; paused: boolean }) =>
adaptersApi.setOverridePaused(type, paused),
onSuccess: () => {
invalidate();
},
onError: (err: Error) => {
pushToast({ title: "Override toggle failed", body: err.message, tone: "error" });
},
});
const reloadMutation = useMutation({
mutationFn: (type: string) => adaptersApi.reload(type),
onSuccess: (result) => {
@ -371,8 +374,6 @@ export function AdapterManager() {
// External adapters that override a builtin type. The server only returns
// one entry per type (the external), so we synthesize a builtin row for
// the builtins section so users can see which builtins are affected.
// The virtual entry's disabled state reflects the TYPE's menu visibility
// (server-side disabled flag), NOT the external adapter's override state.
const overriddenBuiltins = (adapters ?? [])
.filter((a) => a.source === "external" && a.overriddenBuiltin)
.filter((a) => !builtinAdapters.some((b) => b.type === a.type))
@ -383,15 +384,13 @@ export function AdapterManager() {
a.packageName,
a.version ? `v${a.version}` : undefined,
].filter(Boolean).join(" "),
// The override-paused state is client-side and independent of
// the type's server-side menu visibility.
overridePaused: isOverrideDisabled(a.type),
menuDisabled: a.disabled ?? false,
overridePaused: !!a.overridePaused,
menuDisabled: !!a.disabled,
}));
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading adapters...</div>;
const isMutating = installMutation.isPending || removeMutation.isPending || toggleMutation.isPending || reloadMutation.isPending || reinstallMutation.isPending;
const isMutating = installMutation.isPending || removeMutation.isPending || toggleMutation.isPending || overrideMutation.isPending || reloadMutation.isPending || reinstallMutation.isPending;
return (
<div className="space-y-6 max-w-5xl">
@ -546,12 +545,12 @@ export function AdapterManager() {
<ul className="divide-y rounded-md border bg-card">
{externalAdapters.map((adapter) => {
const isBuiltinOverride = adapter.overriddenBuiltin;
const overridePaused = isBuiltinOverride && isOverrideDisabled(adapter.type);
const overridePaused = isBuiltinOverride && !!adapter.overridePaused;
// For overridden builtins, the power button controls the
// client-side override state (not server menu visibility).
// override pause state (not server menu visibility).
const effectiveAdapter: AdapterInfo = isBuiltinOverride
? { ...adapter, disabled: !!overridePaused }
? { ...adapter, disabled: overridePaused }
: adapter;
return (
@ -561,19 +560,13 @@ export function AdapterManager() {
canRemove={true}
onToggle={
isBuiltinOverride
? (type, disabled) => {
setOverrideDisabled(type, disabled);
// useSyncExternalStore handles the re-render;
// also invalidate so other components (e.g. menus)
// eventually pick up the registry change.
invalidate();
}
? (type, disabled) => overrideMutation.mutate({ type, paused: disabled })
: (type, disabled) => toggleMutation.mutate({ type, disabled })
}
onRemove={(type) => setRemoveType(type)}
onReload={(type) => reloadMutation.mutate(type)}
onReinstall={!adapter.isLocalPath ? (type) => setReinstallTarget(adapter) : undefined}
isToggling={isBuiltinOverride ? false : toggleMutation.isPending}
isToggling={isBuiltinOverride ? overrideMutation.isPending : toggleMutation.isPending}
isReloading={reloadMutation.isPending}
isReinstalling={reinstallMutation.isPending}
toggleTitleDisabled={isBuiltinOverride ? "Pause external override" : undefined}