From ef2a89bd69f55cb700685fc0da56347e9b686c55 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Mon, 31 Aug 2026 01:06:43 -0300 Subject: [PATCH] =?UTF-8?q?chore(lint):=20batch=201=20of=20#12146=20?= =?UTF-8?q?=E2=80=94=20dashboard/cli-code=20react-hooks=20violations=20res?= =?UTF-8?q?olved=20(#12160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- config/quality/eslint-suppressions.json | 82 ---------- .../components/AntigravityToolCard.tsx | 45 +++--- .../ClaudeClassifierCompatToggle.tsx | 6 +- .../cli-code/components/ClaudeToolCard.tsx | 137 +++++++++-------- .../components/CliProfileAutoSyncToggles.tsx | 6 +- .../cli-code/components/ClineToolCard.tsx | 93 ++++++------ .../components/CliproxyapiToolCard.tsx | 10 +- .../cli-code/components/CodexToolCard.tsx | 140 +++++++++--------- .../cli-code/components/DroidToolCard.tsx | 139 +++++++++-------- .../cli-code/components/GrokBuildToolCard.tsx | 17 ++- .../components/HermesAgentToolCard.tsx | 91 ++++++------ .../cli-code/components/KiloToolCard.tsx | 69 +++++---- .../cli-code/components/OpenClawToolCard.tsx | 123 ++++++++------- 13 files changed, 466 insertions(+), 492 deletions(-) diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 4ac8f00716..ed9d93836c 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -961,57 +961,11 @@ "src/app/(dashboard)/dashboard/cli-code/components/AntigravityToolCard.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 1 - }, - "react-hooks/immutability": { - "count": 3 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/cli-code/components/ClaudeClassifierCompatToggle.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx": { - "react-hooks/immutability": { - "count": 3 - }, - "react-hooks/set-state-in-effect": { - "count": 2 - } - }, - "src/app/(dashboard)/dashboard/cli-code/components/CliProfileAutoSyncToggles.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 } }, "src/app/(dashboard)/dashboard/cli-code/components/ClineToolCard.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 - }, - "react-hooks/immutability": { - "count": 3 - }, - "react-hooks/set-state-in-effect": { - "count": 2 - } - }, - "src/app/(dashboard)/dashboard/cli-code/components/CliproxyapiToolCard.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": { - "react-hooks/immutability": { - "count": 4 - }, - "react-hooks/set-state-in-effect": { - "count": 2 } }, "src/app/(dashboard)/dashboard/cli-code/components/CopilotToolCard.tsx": { @@ -1019,50 +973,14 @@ "count": 3 } }, - "src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx": { - "react-hooks/immutability": { - "count": 3 - }, - "react-hooks/set-state-in-effect": { - "count": 2 - } - }, - "src/app/(dashboard)/dashboard/cli-code/components/GrokBuildToolCard.tsx": { - "react-hooks/set-state-in-effect": { - "count": 2 - } - }, "src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx": { "@typescript-eslint/no-unused-vars": { "count": 2 - }, - "react-hooks/immutability": { - "count": 1 - }, - "react-hooks/purity": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 } }, "src/app/(dashboard)/dashboard/cli-code/components/KiloToolCard.tsx": { "@typescript-eslint/no-unused-vars": { "count": 2 - }, - "react-hooks/immutability": { - "count": 3 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx": { - "react-hooks/immutability": { - "count": 3 - }, - "react-hooks/set-state-in-effect": { - "count": 2 } }, "src/app/(dashboard)/dashboard/combos/ComboControlCenterClient.tsx": { diff --git a/src/app/(dashboard)/dashboard/cli-code/components/AntigravityToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/AntigravityToolCard.tsx index 8ef681b6b7..f22c7dc9d1 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/AntigravityToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/AntigravityToolCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Card, Button, Badge, Modal, Input, ModelSelectModal } from "@/shared/components"; import { MITM_TOOL_HOSTS } from "@/shared/constants/mitmToolHosts"; import { useTranslations } from "next-intl"; @@ -49,22 +49,12 @@ export default function AntigravityToolCard({ const [modelAliases, setModelAliases] = useState({}); // (#523) Store the key *id* (not the masked string) so the backend can - // resolve the real secret from DB before writing to config files. - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKeyId) { - setSelectedApiKeyId(apiKeys[0].id); - } - }, [apiKeys, selectedApiKeyId]); + // resolve the real secret from DB before writing to config files. Default to + // the first available key while the user hasn't picked one — derived during + // render instead of synced through an effect (react-hooks/set-state-in-effect). + const effectiveApiKeyId = selectedApiKeyId || (apiKeys?.length > 0 ? apiKeys[0].id : ""); - useEffect(() => { - if (isExpanded && !status) { - fetchStatus(); - loadSavedMappings(); - fetchModelAliases(); - } - }, [isExpanded, status]); - - const loadSavedMappings = async () => { + const loadSavedMappings = useCallback(async () => { try { const res = await fetch(`/api/cli-tools/antigravity-mitm/alias?tool=${tool.id}`); if (res.ok) { @@ -78,9 +68,9 @@ export default function AntigravityToolCard({ } catch (error) { console.log("Error loading saved mappings:", error); } - }; + }, [tool.id]); - const fetchStatus = async () => { + const fetchStatus = useCallback(async () => { try { const res = await fetch("/api/cli-tools/antigravity-mitm"); if (res.ok) { @@ -91,9 +81,9 @@ export default function AntigravityToolCard({ console.log("Error fetching status:", error); setStatus({ running: false }); } - }; + }, []); - const fetchModelAliases = async () => { + const fetchModelAliases = useCallback(async () => { try { const res = await fetch("/api/models/alias"); const data = await res.json(); @@ -101,7 +91,16 @@ export default function AntigravityToolCard({ } catch (error) { console.log("Error fetching model aliases:", error); } - }; + }, []); + + useEffect(() => { + if (!(isExpanded && !status)) return; + // 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 Promise.all([fetchStatus(), loadSavedMappings(), fetchModelAliases()]); + })(); + }, [isExpanded, status, fetchStatus, loadSavedMappings, fetchModelAliases]); // MITM elevation is decided by the *server* OS, not by this browser's user // agent. The server reports `isWin` and `needsSudoPassword` in GET status — @@ -135,7 +134,7 @@ export default function AntigravityToolCard({ try { // (#523) Prefer keyId lookup so the backend writes the real key to disk. const selectedKeyId = - selectedApiKeyId?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null); + effectiveApiKeyId?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null); const res = await fetch("/api/cli-tools/antigravity-mitm", { method: "POST", @@ -345,7 +344,7 @@ export default function AntigravityToolCard({ {apiKeys.length > 0 ? ( setSelectedApiKey(e.target.value)} className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50" > diff --git a/src/app/(dashboard)/dashboard/cli-code/components/CliProfileAutoSyncToggles.tsx b/src/app/(dashboard)/dashboard/cli-code/components/CliProfileAutoSyncToggles.tsx index 3bd960e19d..ab2c837964 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/CliProfileAutoSyncToggles.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/CliProfileAutoSyncToggles.tsx @@ -45,7 +45,11 @@ export default function CliProfileAutoSyncToggles() { }, [t]); useEffect(() => { - load(); + // 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 persist = useCallback( diff --git a/src/app/(dashboard)/dashboard/cli-code/components/ClineToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/ClineToolCard.tsx index 3a61305639..f382cd4d8a 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/ClineToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/ClineToolCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; import CliStatusBadge from "./CliStatusBadge"; @@ -9,6 +9,10 @@ import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; +// (#523) Default to the first available key while the user hasn't picked one. +const defaultKeyId = (selectedId, apiKeys) => + selectedId || (apiKeys?.length > 0 ? apiKeys[0].id : ""); + export default function ClineToolCard({ tool, isExpanded = false, @@ -56,32 +60,12 @@ export default function ClineToolCard({ const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null; // (#523) Store the key *id* (not the masked string) so the backend can - // resolve the real secret from DB before writing to config files. - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKeyId) { - setSelectedApiKeyId(apiKeys[0].id); - } - }, [apiKeys, selectedApiKeyId]); + // resolve the real secret from DB before writing to config files. Derived + // during render instead of synced through an effect + // (react-hooks/set-state-in-effect). + const effectiveApiKeyId = defaultKeyId(selectedApiKeyId, apiKeys); - useEffect(() => { - if (isExpanded && !clineStatus) { - checkClineStatus(); - fetchModelAliases(); - fetchBackups(); - } - }, [isExpanded, clineStatus]); - - useEffect(() => { - if (clineStatus?.settings && !hasInitializedModel.current) { - const currentModel = clineStatus.settings.openAiModelId; - if (currentModel) { - setSelectedModel(currentModel); - hasInitializedModel.current = true; - } - } - }, [clineStatus]); - - const fetchModelAliases = async () => { + const fetchModelAliases = useCallback(async () => { try { const res = await fetch("/api/models/alias"); if (res.ok) { @@ -91,9 +75,9 @@ export default function ClineToolCard({ } catch { /* ignore */ } - }; + }, []); - const fetchBackups = async () => { + const fetchBackups = useCallback(async () => { try { const res = await fetch("/api/cli-tools/backups?tool=cline"); if (res.ok) { @@ -103,7 +87,39 @@ export default function ClineToolCard({ } catch { /* ignore */ } - }; + }, []); + + const checkClineStatus = useCallback(async () => { + setCheckingCline(true); + try { + const res = await fetch("/api/cli-tools/cline-settings"); + const data = await res.json(); + setClineStatus(data); + // One-time model initialization from the settings file, right after the + // fetch resolves (was a separate clineStatus effect — moved here so no + // setState runs synchronously inside an effect body). + if (data?.settings && !hasInitializedModel.current) { + const currentModel = data.settings.openAiModelId; + if (currentModel) { + setSelectedModel(currentModel); + hasInitializedModel.current = true; + } + } + } catch (error) { + setClineStatus({ error: error.message }); + } finally { + setCheckingCline(false); + } + }, []); + + useEffect(() => { + if (!(isExpanded && !clineStatus)) return; + // 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 Promise.all([checkClineStatus(), fetchModelAliases(), fetchBackups()]); + })(); + }, [isExpanded, clineStatus, checkClineStatus, fetchModelAliases, fetchBackups]); const handleRestoreBackup = async (backupId) => { setRestoringBackup(backupId); @@ -133,19 +149,6 @@ export default function ClineToolCard({ } }; - const checkClineStatus = async () => { - setCheckingCline(true); - try { - const res = await fetch("/api/cli-tools/cline-settings"); - const data = await res.json(); - setClineStatus(data); - } catch (error) { - setClineStatus({ error: error.message }); - } finally { - setCheckingCline(false); - } - }; - const getEffectiveBaseUrl = () => { if (customBaseUrl) return customBaseUrl; return baseUrl || DEFAULT_DISPLAY_BASE_URL; @@ -161,7 +164,7 @@ export default function ClineToolCard({ : `${effectiveBaseUrl}/v1`; // (#523) Prefer keyId lookup so the backend writes the real key to disk. - const selectedKeyId = selectedApiKeyId?.trim() || null; + const selectedKeyId = effectiveApiKeyId?.trim() || null; const res = await fetch("/api/cli-tools/cline-settings", { method: "POST", @@ -365,7 +368,7 @@ export default function ClineToolCard({ {apiKeys && apiKeys.length > 0 ? ( setSelectedApiKey(e.target.value)} className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50" > @@ -715,7 +719,7 @@ openai_base_url = "${getEffectiveBaseUrl()}" onClick={handleApplySettings} disabled={isApplyDisabled({ selectedModel, - selectedApiKey, + selectedApiKey: effectiveApiKey, cloudEnabled, apiKeys, })} diff --git a/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx index f3dcb2bfd4..aac071decc 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import CliStatusBadge from "./CliStatusBadge"; import { useTranslations } from "next-intl"; @@ -9,6 +9,9 @@ import ProviderIcon from "@/shared/components/ProviderIcon"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; +// (#618) Match any custom:OmniRoute- entry (multi-model). +const isOmniRouteEntry = (m) => typeof m?.id === "string" && m.id.startsWith("custom:OmniRoute"); + export default function DroidToolCard({ tool, isExpanded = false, @@ -45,9 +48,6 @@ export default function DroidToolCard({ const [restoringBackup, setRestoringBackup] = useState(null); const cliReady = !!(droidStatus?.installed && droidStatus?.runnable); - // (#618) Match any custom:OmniRoute- entry (multi-model). - const isOmniRouteEntry = (m) => typeof m?.id === "string" && m.id.startsWith("custom:OmniRoute"); - const getConfigStatus = () => { if (!cliReady) return null; const currentConfig = droidStatus.settings?.customModels?.find(isOmniRouteEntry); @@ -65,22 +65,12 @@ export default function DroidToolCard({ const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null; // (#523) Store the key *id* (not the masked string) so the backend can - // resolve the real secret from DB before writing to config files. - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKeyId) { - setSelectedApiKeyId(apiKeys[0].id); - } - }, [apiKeys, selectedApiKeyId]); + // resolve the real secret from DB before writing to config files. Default to + // the first available key while the user hasn't picked one — derived during + // render instead of synced through an effect (react-hooks/set-state-in-effect). + const effectiveApiKeyId = selectedApiKeyId || (apiKeys?.length > 0 ? apiKeys[0].id : ""); - useEffect(() => { - if (isExpanded && !droidStatus) { - checkDroidStatus(); - fetchModelAliases(); - fetchBackups(); - } - }, [isExpanded, droidStatus]); - - const fetchModelAliases = async () => { + const fetchModelAliases = useCallback(async () => { try { const res = await fetch("/api/models/alias"); const data = await res.json(); @@ -88,34 +78,67 @@ export default function DroidToolCard({ } catch (error) { console.log("Error fetching model aliases:", error); } - }; + }, []); - useEffect(() => { - if (droidStatus?.installed && !hasInitializedModel.current) { - hasInitializedModel.current = true; - // (#618) Pre-fill the multi-model list from every custom:OmniRoute- - // entry, preserving the original index order. - const existing = (droidStatus.settings?.customModels || []) - .filter(isOmniRouteEntry) - .slice() - .sort((a, b) => (a.index || 0) - (b.index || 0)); - if (existing.length > 0) { - setModelList(existing.map((m) => m.model).filter(Boolean)); - const first = existing[0]; - // apiKey may be a structured secret reference (object) rather than a - // plaintext string. Only match on strings. - if (typeof first?.apiKey === "string" && first.apiKey) { - // (#523) Keys from /api/keys are masked. Match by prefix/suffix. - const fileKeyPrefix = first.apiKey.slice(0, 8); - const fileKeySuffix = first.apiKey.slice(-4); - const matchedKey = apiKeys?.find( - (k) => k.key && k.key.startsWith(fileKeyPrefix) && k.key.endsWith(fileKeySuffix) - ); - if (matchedKey) setSelectedApiKeyId(matchedKey.id); + // ── Backups ── + const fetchBackups = useCallback(async () => { + try { + const res = await fetch("/api/cli-tools/backups?tool=droid"); + const data = await res.json(); + if (res.ok) setBackups(data.backups || []); + } catch (error) { + console.log("Error fetching backups:", error); + } + }, []); + + const checkDroidStatus = useCallback(async () => { + setCheckingDroid(true); + try { + const res = await fetch("/api/cli-tools/droid-settings"); + const data = await res.json(); + setDroidStatus(data); + // One-time form initialization from the settings file, right after the + // fetch resolves (was a separate droidStatus effect — moved here so no + // setState runs synchronously inside an effect body). + if (data?.installed && !hasInitializedModel.current) { + hasInitializedModel.current = true; + // (#618) Pre-fill the multi-model list from every custom:OmniRoute- + // entry, preserving the original index order. + const existing = (data.settings?.customModels || []) + .filter(isOmniRouteEntry) + .slice() + .sort((a, b) => (a.index || 0) - (b.index || 0)); + if (existing.length > 0) { + setModelList(existing.map((m) => m.model).filter(Boolean)); + const first = existing[0]; + // apiKey may be a structured secret reference (object) rather than a + // plaintext string. Only match on strings. + if (typeof first?.apiKey === "string" && first.apiKey) { + // (#523) Keys from /api/keys are masked. Match by prefix/suffix. + const fileKeyPrefix = first.apiKey.slice(0, 8); + const fileKeySuffix = first.apiKey.slice(-4); + const matchedKey = apiKeys?.find( + (k) => k.key && k.key.startsWith(fileKeyPrefix) && k.key.endsWith(fileKeySuffix) + ); + if (matchedKey) setSelectedApiKeyId(matchedKey.id); + } } } + } catch (error) { + setDroidStatus({ installed: false, error: error.message }); + } finally { + setCheckingDroid(false); } - }, [droidStatus, apiKeys]); + }, [apiKeys]); + + useEffect(() => { + if (!(isExpanded && !droidStatus)) return; + // 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 Promise.all([checkDroidStatus(), fetchModelAliases(), fetchBackups()]); + })(); + }, [isExpanded, droidStatus, checkDroidStatus, fetchModelAliases, fetchBackups]); // (#618) Multi-model list manipulation helpers. const addModel = (value) => { @@ -126,19 +149,6 @@ export default function DroidToolCard({ }; const removeModel = (id) => setModelList((prev) => prev.filter((m) => m !== id)); - const checkDroidStatus = async () => { - setCheckingDroid(true); - try { - const res = await fetch("/api/cli-tools/droid-settings"); - const data = await res.json(); - setDroidStatus(data); - } catch (error) { - setDroidStatus({ installed: false, error: error.message }); - } finally { - setCheckingDroid(false); - } - }; - const getEffectiveBaseUrl = () => { const url = customBaseUrl || baseUrl; return url.endsWith("/v1") ? url : `${url}/v1`; @@ -155,7 +165,7 @@ export default function DroidToolCard({ try { // (#523) Prefer keyId lookup so the backend writes the real key to disk. const selectedKeyId = - selectedApiKeyId?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null); + effectiveApiKeyId?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null); const res = await fetch("/api/cli-tools/droid-settings", { method: "POST", @@ -227,17 +237,6 @@ export default function DroidToolCard({ setModalOpen(false); }; - // ── Backups ── - const fetchBackups = async () => { - try { - const res = await fetch("/api/cli-tools/backups?tool=droid"); - const data = await res.json(); - if (res.ok) setBackups(data.backups || []); - } catch (error) { - console.log("Error fetching backups:", error); - } - }; - const handleRestoreBackup = async (backupId) => { setRestoringBackup(backupId); setMessage(null); @@ -269,7 +268,7 @@ export default function DroidToolCard({ const getManualConfigs = () => { // (#523) Look up the key object by id to get the masked display value. - const selectedKeyObj = apiKeys?.find((k) => k.id === selectedApiKeyId); + const selectedKeyObj = apiKeys?.find((k) => k.id === effectiveApiKeyId); const keyToDisplay = selectedKeyObj?.key || (!cloudEnabled ? "sk_omniroute" : ""); @@ -415,7 +414,7 @@ export default function DroidToolCard({ {apiKeys.length > 0 ? ( setSelectedKeyId(event.target.value)} > diff --git a/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx index 39f4bff6c2..3d867a798a 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useRef, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { useTranslations } from "next-intl"; import { Card, Button, ModelSelectModal } from "@/shared/components"; @@ -109,12 +109,14 @@ export default function HermesAgentToolCard({ // those providers never surface in the Hermes Agent role picker (#7151). const [modelAliases, setModelAliases] = useState({}); - // Track whether we have already seeded from batchStatus on this expand - const seededFromBatchRef = useRef(false); + // Render-stable "now" snapshot for the relative-time chip — Date.now() is + // impure during render (react-hooks/purity), so capture it once via a lazy + // state initializer. Minute-level granularity makes the frozen value fine. + const [nowTs] = useState(() => Date.now()); function formatTimeSince(iso: string): string { const then = new Date(iso).getTime(); - const diff = Date.now() - then; + const diff = nowTs - then; const days = Math.floor(diff / (1000 * 60 * 60 * 24)); if (days > 0) return t("daysAgoShort", { count: days }); @@ -143,37 +145,7 @@ export default function HermesAgentToolCard({ } }, []); - useEffect(() => { - if (!isExpanded) { - // Reset seed flag when collapsed so it can seed again on next expand - seededFromBatchRef.current = false; - setPreviewYaml(null); - setFirstSetupAt(null); - return; - } - // Phase 3: Seed from detector snapshot (batchStatus) for instant UI — once per expand. - // NOTE: currentRoles is intentionally NOT a dependency. loadCurrentConfig() below sets - // currentRoles to a fresh object on every fetch; if currentRoles were a dep, the effect - // would re-fire → refetch → setCurrentRoles → re-fire … an infinite loop. On the detail - // page isExpanded is hardcoded true, so that loop spun forever (the "loading forever" + - // console spam of /api/cli-tools/hermes-agent-settings). We read currentRoles only via a - // functional update so the emptiness guard sees the latest value without subscribing to it. - if (!seededFromBatchRef.current && batchStatus?.hermesAgentRoles) { - seededFromBatchRef.current = true; - setCurrentRoles((prev) => { - if (Object.keys(prev).length > 0) return prev; - const seeded: Record = {}; - Object.entries(batchStatus.hermesAgentRoles).forEach(([role, info]: [string, any]) => { - seeded[role] = { model: info.model, provider: info.provider }; - }); - return seeded; - }); - } - loadCurrentConfig(); - fetchModelAliases(); - }, [isExpanded, batchStatus, loadCurrentConfig]); - - const fetchModelAliases = async () => { + const fetchModelAliases = useCallback(async () => { try { const res = await fetch("/api/models/alias"); const data = await res.json(); @@ -181,6 +153,40 @@ export default function HermesAgentToolCard({ } catch (error) { console.warn("Error fetching model aliases:", error); } + }, []); + + useEffect(() => { + if (!isExpanded) return; + // 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 Promise.all([loadCurrentConfig(), fetchModelAliases()]); + })(); + }, [isExpanded, loadCurrentConfig, fetchModelAliases]); + + // Phase 3: seed the visible role data from the detector snapshot + // (batchStatus) for instant UI while /api/cli-tools/hermes-agent-settings is + // in flight — derived during render instead of copied into state + // (react-hooks/set-state-in-effect). Freshly loaded roles always win once + // loadCurrentConfig() resolves and populates currentRoles. + const seededRoles = useMemo(() => { + const seeded: Record = {}; + Object.entries(batchStatus?.hermesAgentRoles || {}).forEach(([role, info]: [string, any]) => { + seeded[role] = { model: info.model, provider: info.provider }; + }); + return seeded; + }, [batchStatus]); + const displayRoles = Object.keys(currentRoles).length > 0 ? currentRoles : seededRoles; + + const handleToggle = () => { + // Collapsing: drop the stale preview and setup timestamp (was done by a + // collapse effect — moved into the toggle handler so no setState runs + // synchronously inside an effect body). + if (isExpanded) { + setPreviewYaml(null); + setFirstSetupAt(null); + } + onToggle(); }; const setRoleSelection = (roleId: string, model: string, provider = "OmniRoute") => { @@ -211,7 +217,7 @@ export default function HermesAgentToolCard({ model: sel.model, })); } else { - payloadSelections = Object.entries(currentRoles) + payloadSelections = Object.entries(displayRoles) .filter(([_, info]) => info && info.model) .map(([role, info]) => ({ role, model: info.model })); } @@ -334,7 +340,10 @@ export default function HermesAgentToolCard({ return ( {/* Collapsed header — exact match to OpenClaw / Kilo / other Auto-Configured entries */} -
+
terminal @@ -355,9 +364,7 @@ export default function HermesAgentToolCard({ )} - {(Object.keys(currentRoles).length > 0 || - Object.keys(selections).length > 0 || - Object.keys(batchStatus?.hermesAgentRoles || {}).length > 0) && ( + {(Object.keys(displayRoles).length > 0 || Object.keys(selections).length > 0) && ( {t("hermesConfiguredRoles", { configured: configuredRolesCount, @@ -416,7 +423,7 @@ export default function HermesAgentToolCard({ {/* Roles list — flat consistent rows (no nested Card.Section boxes) */}
{HERMES_ROLES.map((role) => { - const current = currentRoles[role.id]; + const current = displayRoles[role.id]; const sel = selections[role.id]; // displayed model prefers pending user choice, falls back to real current from YAML @@ -550,7 +557,7 @@ export default function HermesAgentToolCard({ disabled={ isSaving || isLoading || - (Object.keys(selections).length === 0 && Object.keys(currentRoles).length === 0) + (Object.keys(selections).length === 0 && Object.keys(displayRoles).length === 0) } loading={isPreviewLoading} > diff --git a/src/app/(dashboard)/dashboard/cli-code/components/KiloToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/KiloToolCard.tsx index d682f913ca..79ab9bc8cf 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/KiloToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/KiloToolCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; import CliStatusBadge from "./CliStatusBadge"; @@ -52,22 +52,12 @@ export default function KiloToolCard({ const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null; // (#523) Store the key *id* (not the masked string) so the backend can - // resolve the real secret from DB before writing to config files. - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKeyId) { - setSelectedApiKeyId(apiKeys[0].id); - } - }, [apiKeys, selectedApiKeyId]); + // resolve the real secret from DB before writing to config files. Default to + // the first available key while the user hasn't picked one — derived during + // render instead of synced through an effect (react-hooks/set-state-in-effect). + const effectiveApiKeyId = selectedApiKeyId || (apiKeys?.length > 0 ? apiKeys[0].id : ""); - useEffect(() => { - if (isExpanded && !kiloStatus) { - checkKiloStatus(); - fetchModelAliases(); - fetchBackups(); - } - }, [isExpanded, kiloStatus]); - - const fetchModelAliases = async () => { + const fetchModelAliases = useCallback(async () => { try { const res = await fetch("/api/models/alias"); if (res.ok) { @@ -77,9 +67,9 @@ export default function KiloToolCard({ } catch { /* ignore */ } - }; + }, []); - const fetchBackups = async () => { + const fetchBackups = useCallback(async () => { try { const res = await fetch("/api/cli-tools/backups?tool=kilo"); if (res.ok) { @@ -89,7 +79,29 @@ export default function KiloToolCard({ } catch { /* ignore */ } - }; + }, []); + + const checkKiloStatus = useCallback(async () => { + setCheckingKilo(true); + try { + const res = await fetch("/api/cli-tools/kilo-settings"); + const data = await res.json(); + setKiloStatus(data); + } catch (error) { + setKiloStatus({ error: error.message }); + } finally { + setCheckingKilo(false); + } + }, []); + + useEffect(() => { + if (!(isExpanded && !kiloStatus)) return; + // 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 Promise.all([checkKiloStatus(), fetchModelAliases(), fetchBackups()]); + })(); + }, [isExpanded, kiloStatus, checkKiloStatus, fetchModelAliases, fetchBackups]); const handleRestoreBackup = async (backupId) => { setRestoringBackup(backupId); @@ -119,19 +131,6 @@ export default function KiloToolCard({ } }; - const checkKiloStatus = async () => { - setCheckingKilo(true); - try { - const res = await fetch("/api/cli-tools/kilo-settings"); - const data = await res.json(); - setKiloStatus(data); - } catch (error) { - setKiloStatus({ error: error.message }); - } finally { - setCheckingKilo(false); - } - }; - const getEffectiveBaseUrl = () => { if (customBaseUrl) return customBaseUrl; return baseUrl || DEFAULT_DISPLAY_BASE_URL; @@ -147,7 +146,7 @@ export default function KiloToolCard({ : `${effectiveBaseUrl}/v1`; // (#523) Prefer keyId lookup so the backend writes the real key to disk. - const selectedKeyId = selectedApiKeyId?.trim() || null; + const selectedKeyId = effectiveApiKeyId?.trim() || null; const res = await fetch("/api/cli-tools/kilo-settings", { method: "POST", @@ -367,7 +366,7 @@ export default function KiloToolCard({ {apiKeys && apiKeys.length > 0 ? ( setSelectedApiKeyId(e.target.value)} className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50" >