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" >