Files
OmniRoute/src/app/(dashboard)/dashboard/cli-code/components/ClaudeClassifierCompatToggle.tsx
Diego Rodrigues de Sa e Souza ef2a89bd69 chore(lint): batch 1 of #12146 — dashboard/cli-code react-hooks violations resolved (#12160)
* chore(lint): batch 1 of #12146 — resolve the react-hooks compiler violations in dashboard/cli-code

Real refactors, no suppressions — the 42 frozen react-hooks/* entries for the
12 dashboard/cli-code files (plus Antigravity's exhaustive-deps one) are
removed from config/quality/eslint-suppressions.json and the files now lint
clean under the React Compiler rules.

Techniques, per pattern:

- set-state-in-effect ("default API key" effects — Antigravity, Claude, Cline,
  Codex, Droid, GrokBuild, Kilo, OpenClaw): the setState-in-effect that copied
  apiKeys[0].id into the selection state is deleted; an `effective*` value is
  derived during render (`selected || apiKeys[0]?.id`) and used by the select
  and the submit handlers. Behavior identical, one less render pass.

- immutability ("accessed before declared") + set-state-in-effect on the
  expand-time loaders (all tool cards): the fetchers (checkXStatus,
  fetchModelAliases, fetchBackups, fetchProfiles, loadSavedMappings) are
  hoisted above the effect as useCallback with correct deps, listed in the
  effect deps, and invoked through an async continuation
  (`void (async () => { await Promise.all([...]) })()`) so no setState runs
  synchronously in the effect body.

- set-state-in-effect ("init form from fetched status" effects — Claude,
  Cline, Codex, Droid, OpenClaw): the status-parsing effects are deleted and
  their logic now runs inside checkXStatus right after the fetch resolves
  (setState after await), keeping the same one-time ref guards. Codex's config
  parser became syncFormFromStatus(), called on both success and error paths.

- HermesAgentToolCard: Date.now() in render (purity) is snapshotted once via a
  lazy useState initializer; the batchStatus seeding effect is replaced by a
  derived `displayRoles` (useMemo over batchStatus with currentRoles taking
  precedence); the collapse-reset effect moved into the header toggle handler.

- ClaudeClassifierCompatToggle / CliProfileAutoSyncToggles / Cliproxyapi /
  GrokBuild: mount/expand loads wrapped in the same async continuation.

- DroidToolCard's isOmniRouteEntry helper hoisted to module scope (pure).

Validation: eslint with suppressions --max-warnings 0 on the 12 files (clean),
scripts/check/check-dashboard-typecheck.mjs (OK, within frozen baseline),
vitest UI suites for the touched cards (15 files / 57 tests green, plus the 3
quarantined #8618 files run explicitly: 27 tests green), and the node-native
cli-code tests (61 tests green).

Refs #12146

* chore(lint): batch 1 follow-up — hoist the settings-init helpers so the cognitive gate stays flat

The first pass folded the one-time form init into the status fetchers, which pushed
sonarjs/cognitive-complexity to 1 in Claude/Cline/OpenClaw tool cards (caught by the
new-code gate on the PR). The init logic now lives in module-level helpers
(initXFormFromSettings + defaultKeyId); complexityNewCode=-1, cognitiveComplexityNewCode=0.
2026-08-31 01:06:43 -03:00

106 lines
4.0 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
type CompatMode = "off" | "auto" | "always";
const MODES: CompatMode[] = ["off", "auto", "always"];
const MODE_STYLES: Record<CompatMode, string> = {
off: "bg-black/5 dark:bg-white/5 text-text-muted border-border",
auto: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/40",
always: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/40",
};
function isCompatMode(value: unknown): value is CompatMode {
return value === "off" || value === "auto" || value === "always";
}
/**
* Opt-in toggle (default "off") for Claude Code's auto-permission classifier compat mode.
*
* Claude Code's `--permission-mode auto` sends an internal `/v1/messages` security-classifier
* request that requires the response to START with `<block>no</block>` (ALLOW) / `<block>yes</block>`
* (BLOCK). When a combo/fallback route sends that call to a cheap model returning empty content,
* Claude Code fails closed on every gated action. "auto" detects the classifier request and
* short-circuits with a synthetic ALLOW response without calling upstream; "always" applies it to
* every Claude-format request. Cycles off → auto → always via the existing /api/settings PATCH.
*/
export default function ClaudeClassifierCompatToggle() {
const t = useTranslations("cliTools");
const [mode, setMode] = useState<CompatMode>("off");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/settings");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setMode(isCompatMode(data?.claudeClassifierCompat) ? data.claudeClassifierCompat : "off");
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : t("classifierCompatLoadFailed"));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
// Load in an async continuation so every setState happens after an await
// (react-hooks/set-state-in-effect: no synchronous setState in effect bodies).
void (async () => {
await load();
})();
}, [load]);
const cycle = useCallback(async () => {
const next = MODES[(MODES.indexOf(mode) + 1) % MODES.length];
const previous = mode;
setMode(next); // optimistic
setSaving(true);
setError(null);
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ claudeClassifierCompat: next }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (err) {
setMode(previous); // revert on failure
setError(err instanceof Error ? err.message : t("failedSave"));
} finally {
setSaving(false);
}
}, [mode, t]);
return (
<div className="rounded-lg border border-border bg-surface/40 p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h4 className="text-xs font-semibold text-text-main">{t("classifierCompatTitle")}</h4>
<p className="text-xs text-text-muted">
{t.rich("classifierCompatDescription", {
code: (chunks) => <code>{chunks}</code>,
})}
</p>
</div>
<button
type="button"
onClick={cycle}
disabled={loading || saving}
title={t("classifierCompatCycle")}
className={`shrink-0 rounded border px-3 py-1.5 text-xs font-medium uppercase tracking-wide transition-colors disabled:opacity-50 ${MODE_STYLES[mode]}`}
>
{t(`classifierCompatMode.${mode}`)}
</button>
</div>
{error ? <p className="mt-2 text-xs text-red-500">{error}</p> : null}
</div>
);
}