Compare commits

...

1 Commits

Author SHA1 Message Date
diegosouzapw
f6b7c51321 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
2026-08-30 20:23:45 -03:00
13 changed files with 430 additions and 492 deletions

View File

@@ -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": {

View File

@@ -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({
</span>
{apiKeys.length > 0 ? (
<select
value={selectedApiKeyId}
value={effectiveApiKeyId}
onChange={(e) => 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"
>

View File

@@ -50,7 +50,11 @@ export default function ClaudeClassifierCompatToggle() {
}, [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 cycle = useCallback(async () => {

View File

@@ -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";
@@ -64,23 +64,13 @@ export default function ClaudeToolCard({
// Use batch status as fallback when card hasn't been expanded yet
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
useEffect(() => {
// (#523) Store the key *id* (not the masked string) so the backend can
// resolve the real secret from DB before writing to settings.json.
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].id);
}
}, [apiKeys, selectedApiKey]);
// (#523) Store the key *id* (not the masked string) so the backend can
// resolve the real secret from DB before writing to settings.json. 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 effectiveApiKey = selectedApiKey || (apiKeys?.length > 0 ? apiKeys[0].id : "");
useEffect(() => {
if (isExpanded && !claudeStatus) {
checkClaudeStatus();
fetchModelAliases();
fetchBackups();
}
}, [isExpanded, claudeStatus]);
const fetchModelAliases = async () => {
const fetchModelAliases = useCallback(async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
@@ -88,46 +78,66 @@ export default function ClaudeToolCard({
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
}, []);
useEffect(() => {
if (claudeStatus?.installed && !hasInitializedModels.current) {
hasInitializedModels.current = true;
const env = claudeStatus.settings?.env || {};
tool.defaultModels.forEach((model) => {
if (model.envKey) {
const value = env[model.envKey] || model.defaultValue || "";
// Only sync initial values from file once
if (value) {
onModelMappingChange(model.alias, value);
}
}
});
// Restore selected key from file: match token stored in file against known keys
const tokenFromFile = getStoredClaudeAuthValue(env);
if (tokenFromFile) {
// (#523) Keys from /api/keys are masked (first 8 + "****" + last 4).
// Mask the token from file to compare against the masked list.
const maskedToken = tokenFromFile.slice(0, 8) + "****" + tokenFromFile.slice(-4);
const matchedKey = apiKeys?.find((k) => k.key === maskedToken);
if (matchedKey) setSelectedApiKey(matchedKey.id);
}
// ── Backups ──
const fetchBackups = useCallback(async () => {
try {
const res = await fetch("/api/cli-tools/backups?tool=claude");
const data = await res.json();
if (res.ok) setBackups(data.backups || []);
} catch (error) {
console.log("Error fetching backups:", error);
}
}, [claudeStatus, apiKeys, tool.defaultModels, onModelMappingChange]);
}, []);
const checkClaudeStatus = async () => {
const checkClaudeStatus = useCallback(async () => {
setCheckingClaude(true);
try {
const res = await fetch("/api/cli-tools/claude-settings");
const data = await res.json();
setClaudeStatus(data);
// One-time form initialization from the settings file, right after the
// fetch resolves (was a separate claudeStatus effect — moved here so no
// setState runs synchronously inside an effect body).
if (data?.installed && !hasInitializedModels.current) {
hasInitializedModels.current = true;
const env = data.settings?.env || {};
tool.defaultModels.forEach((model) => {
if (model.envKey) {
const value = env[model.envKey] || model.defaultValue || "";
// Only sync initial values from file once
if (value) {
onModelMappingChange(model.alias, value);
}
}
});
// Restore selected key from file: match token stored in file against known keys
const tokenFromFile = getStoredClaudeAuthValue(env);
if (tokenFromFile) {
// (#523) Keys from /api/keys are masked (first 8 + "****" + last 4).
// Mask the token from file to compare against the masked list.
const maskedToken = tokenFromFile.slice(0, 8) + "****" + tokenFromFile.slice(-4);
const matchedKey = apiKeys?.find((k) => k.key === maskedToken);
if (matchedKey) setSelectedApiKey(matchedKey.id);
}
}
} catch (error) {
setClaudeStatus({ installed: false, error: error.message });
} finally {
setCheckingClaude(false);
}
};
}, [apiKeys, tool.defaultModels, onModelMappingChange]);
useEffect(() => {
if (!(isExpanded && !claudeStatus)) 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([checkClaudeStatus(), fetchModelAliases(), fetchBackups()]);
})();
}, [isExpanded, claudeStatus, checkClaudeStatus, fetchModelAliases, fetchBackups]);
const getEffectiveBaseUrl = () => {
const url = customBaseUrl || baseUrl;
@@ -148,7 +158,7 @@ export default function ClaudeToolCard({
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
// If no key is selected, leave auth unset so local installs can rely on
// anonymous access instead of persisting a fake placeholder token.
const selectedKeyId = selectedApiKey?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
const selectedKeyId = effectiveApiKey?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
tool.defaultModels.forEach((model) => {
const targetModel = modelMappings[model.alias] || model.defaultValue || "";
@@ -225,7 +235,7 @@ export default function ClaudeToolCard({
// Generate settings.json content for manual copy
const getManualConfigs = () => {
const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() };
if (selectedApiKey && selectedApiKey.trim()) {
if (effectiveApiKey && effectiveApiKey.trim()) {
env.ANTHROPIC_AUTH_TOKEN = "<API_KEY_FROM_DASHBOARD>";
} else if (cloudEnabled) {
env.ANTHROPIC_AUTH_TOKEN = "<API_KEY_FROM_DASHBOARD>";
@@ -244,17 +254,6 @@ export default function ClaudeToolCard({
];
};
// ── Backups ──
const fetchBackups = async () => {
try {
const res = await fetch("/api/cli-tools/backups?tool=claude");
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);
@@ -436,7 +435,7 @@ export default function ClaudeToolCard({
</span>
{apiKeys.length > 0 ? (
<select
value={selectedApiKey}
value={effectiveApiKey}
onChange={(e) => 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"
>

View File

@@ -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(

View File

@@ -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";
@@ -56,32 +56,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. 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 && !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 +71,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 +83,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 +145,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 +160,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 +364,7 @@ export default function ClineToolCard({
<label className="text-sm text-text-muted">{t("apiKey")}</label>
{apiKeys && apiKeys.length > 0 ? (
<select
value={selectedApiKeyId}
value={effectiveApiKeyId}
onChange={(e) => setSelectedApiKeyId(e.target.value)}
className="px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
>
@@ -483,7 +482,7 @@ export default function ClineToolCard({
onApply: handleManualConfig,
currentConfig: {
model: selectedModel,
apiKey: apiKeys?.find((k) => k.id === selectedApiKeyId)?.key || "",
apiKey: apiKeys?.find((k) => k.id === effectiveApiKeyId)?.key || "",
baseUrl: customBaseUrl || baseUrl,
},
} as any)}

View File

@@ -56,10 +56,12 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
}, []);
useEffect(() => {
if (isExpanded) {
fetchStatus();
fetchUpdateInfo();
}
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([fetchStatus(), fetchUpdateInfo()]);
})();
}, [isExpanded, fetchStatus, fetchUpdateInfo]);
const apiCall = async (action: string, body?: Record<string, unknown>) => {

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
@@ -50,23 +50,13 @@ export default function CodexToolCard({
const [restoringBackup, setRestoringBackup] = useState(null);
const cliReady = !!(codexStatus?.installed && codexStatus?.runnable);
useEffect(() => {
// Store the key *id* so the backend can resolve the real secret from DB
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].id);
}
}, [apiKeys, selectedApiKey]);
// Store the key *id* so the backend can resolve the real secret from DB.
// 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 effectiveApiKey = selectedApiKey || (apiKeys?.length > 0 ? apiKeys[0].id : "");
useEffect(() => {
if (isExpanded && !codexStatus) {
checkCodexStatus();
fetchModelAliases();
fetchProfiles();
fetchBackups();
}
}, [isExpanded, codexStatus]);
const fetchModelAliases = async () => {
const fetchModelAliases = useCallback(async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
@@ -74,26 +64,50 @@ export default function CodexToolCard({
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
}, []);
// Parse config content
useEffect(() => {
if (codexStatus && !codexStatus.config) {
// ── Profiles ──
const fetchProfiles = useCallback(async () => {
try {
const res = await fetch("/api/cli-tools/codex-profiles");
const data = await res.json();
if (res.ok) setProfiles(data.profiles || []);
} catch (error) {
console.log("Error fetching profiles:", error);
}
}, []);
// ── Backups ──
const fetchBackups = useCallback(async () => {
try {
const res = await fetch("/api/cli-tools/backups?tool=codex");
const data = await res.json();
if (res.ok) setBackups(data.backups || []);
} catch (error) {
console.log("Error fetching backups:", error);
}
}, []);
// Parse config content and sync the form fields from a freshly fetched
// status (was a separate codexStatus effect — runs right after each fetch
// instead so no setState happens synchronously inside an effect body).
const syncFormFromStatus = useCallback((status) => {
if (status && !status.config) {
setWireApi("responses");
}
if (codexStatus?.config) {
const modelMatch = codexStatus.config.match(/^model\s*=\s*"([^"]+)"/im);
if (status?.config) {
const modelMatch = status.config.match(/^model\s*=\s*"([^"]+)"/im);
if (modelMatch) setSelectedModel(modelMatch[1]);
const effortMatch = codexStatus.config.match(/^model_reasoning_effort\s*=\s*"([^"]+)"/im);
const effortMatch = status.config.match(/^model_reasoning_effort\s*=\s*"([^"]+)"/im);
if (effortMatch) setReasoningEffort(effortMatch[1]);
const wireMatch = codexStatus.config.match(/^wire_api\s*=\s*"([^"]+)"/im);
const wireMatch = status.config.match(/^wire_api\s*=\s*"([^"]+)"/im);
setWireApi(wireMatch?.[1] || "responses");
const newMappings: Record<string, string> = {};
const migrationsBlock = codexStatus.config.split("[notice.model_migrations]")[1];
const migrationsBlock = status.config.split("[notice.model_migrations]")[1];
if (migrationsBlock) {
const nextSectionIdx = migrationsBlock.indexOf("[");
const chunk =
@@ -106,7 +120,32 @@ export default function CodexToolCard({
}
setModelMappings(newMappings);
}
}, [codexStatus]);
}, []);
const checkCodexStatus = useCallback(async () => {
setCheckingCodex(true);
try {
const res = await fetch("/api/cli-tools/codex-settings");
const data = await res.json();
setCodexStatus(data);
syncFormFromStatus(data);
} catch (error) {
const fallback = { installed: false, error: error.message };
setCodexStatus(fallback);
syncFormFromStatus(fallback);
} finally {
setCheckingCodex(false);
}
}, [syncFormFromStatus]);
useEffect(() => {
if (!(isExpanded && !codexStatus)) 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([checkCodexStatus(), fetchModelAliases(), fetchProfiles(), fetchBackups()]);
})();
}, [isExpanded, codexStatus, checkCodexStatus, fetchModelAliases, fetchProfiles, fetchBackups]);
const getConfigStatus = () => {
if (!cliReady) return null;
@@ -127,30 +166,17 @@ export default function CodexToolCard({
const getDisplayUrl = () => normalizeCodexBaseUrl(customBaseUrl || baseUrl, wireApi);
const checkCodexStatus = async () => {
setCheckingCodex(true);
try {
const res = await fetch("/api/cli-tools/codex-settings");
const data = await res.json();
setCodexStatus(data);
} catch (error) {
setCodexStatus({ installed: false, error: error.message });
} finally {
setCheckingCodex(false);
}
};
const handleApplySettings = async () => {
setApplying(true);
setMessage(null);
try {
// Use sk_omniroute for localhost if no key, otherwise use selected key
const keyToUse =
selectedApiKey && selectedApiKey.trim()
? selectedApiKey
effectiveApiKey && effectiveApiKey.trim()
? effectiveApiKey
: !cloudEnabled
? "sk_omniroute"
: selectedApiKey;
: effectiveApiKey;
// Send both apiKey (as fallback) and keyId to look up the unmasked string natively
const res = await fetch("/api/cli-tools/codex-settings", {
@@ -159,7 +185,7 @@ export default function CodexToolCard({
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
keyId: selectedApiKey,
keyId: effectiveApiKey,
model: selectedModel || CODEX_DEFAULT_MODELS[0],
reasoningEffort,
wireApi,
@@ -222,17 +248,6 @@ export default function CodexToolCard({
setModalTarget(null);
};
// ── Profiles ──
const fetchProfiles = async () => {
try {
const res = await fetch("/api/cli-tools/codex-profiles");
const data = await res.json();
if (res.ok) setProfiles(data.profiles || []);
} catch (error) {
console.log("Error fetching profiles:", error);
}
};
const handleSaveProfile = async () => {
if (!newProfileName.trim()) return;
setSavingProfile(true);
@@ -305,17 +320,6 @@ export default function CodexToolCard({
}
};
// ── Backups ──
const fetchBackups = async () => {
try {
const res = await fetch("/api/cli-tools/backups?tool=codex");
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);
@@ -554,7 +558,7 @@ openai_base_url = "${getEffectiveBaseUrl()}"
</span>
{apiKeys.length > 0 ? (
<select
value={selectedApiKey}
value={effectiveApiKey}
onChange={(e) => 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,
})}

View File

@@ -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-<i> 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-<i> 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-<i>
// 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-<i>
// 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" : "<API_KEY_FROM_DASHBOARD>");
@@ -415,7 +414,7 @@ export default function DroidToolCard({
</span>
{apiKeys.length > 0 ? (
<select
value={selectedApiKeyId}
value={effectiveApiKeyId}
onChange={(e) => 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"
>

View File

@@ -135,9 +135,10 @@ export default function GrokBuildToolCard({
const [showBackups, setShowBackups] = useState(false);
const [restoringBackup, setRestoringBackup] = useState<string | null>(null);
useEffect(() => {
if (!selectedKeyId && apiKeys[0]?.id) setSelectedKeyId(apiKeys[0].id);
}, [apiKeys, selectedKeyId]);
// 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 effectiveKeyId = selectedKeyId || apiKeys[0]?.id || "";
const hydrateStatus = useCallback((next: GrokStatus) => {
setStatus(next);
@@ -219,7 +220,11 @@ export default function GrokBuildToolCard({
useEffect(() => {
if (!isExpanded) return;
void Promise.all([refreshStatus(), refreshEndpoints(), refreshBackups()]);
// 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([refreshStatus(), refreshEndpoints(), refreshBackups()]);
})();
}, [isExpanded, refreshBackups, refreshEndpoints, refreshStatus]);
const baseUrl = useMemo(() => {
@@ -277,7 +282,7 @@ export default function GrokBuildToolCard({
headers: { "content-type": "application/json" },
body: JSON.stringify({
baseUrl,
keyId: selectedKeyId || null,
keyId: effectiveKeyId || null,
model,
contextWindow: selectedContext(model),
subagentModels: Object.fromEntries(
@@ -468,7 +473,7 @@ export default function GrokBuildToolCard({
<select
id="grok-build-api-key"
className={inputClass}
value={selectedKeyId}
value={effectiveKeyId}
onChange={(event) => setSelectedKeyId(event.target.value)}
>
<option value="">Use the OmniRoute default key</option>

View File

@@ -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<string, any> = {};
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<string, any> = {};
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 (
<Card padding="sm" className="overflow-hidden">
{/* Collapsed header — exact match to OpenClaw / Kilo / other Auto-Configured entries */}
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
<div
className="flex items-center justify-between hover:cursor-pointer"
onClick={handleToggle}
>
<div className="flex items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<span className="material-symbols-outlined text-[22px] text-text-muted">terminal</span>
@@ -355,9 +364,7 @@ export default function HermesAgentToolCard({
</span>
)}
</h3>
{(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) && (
<span className="text-[10px] px-1.5 py-px rounded bg-emerald-500/10 text-emerald-600">
{t("hermesConfiguredRoles", {
configured: configuredRolesCount,
@@ -416,7 +423,7 @@ export default function HermesAgentToolCard({
{/* Roles list — flat consistent rows (no nested Card.Section boxes) */}
<div className="flex flex-col gap-2">
{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}
>

View File

@@ -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({
<label className="text-sm text-text-muted">{t("apiKey")}</label>
{apiKeys && apiKeys.length > 0 ? (
<select
value={selectedApiKeyId}
value={effectiveApiKeyId}
onChange={(e) => setSelectedApiKeyId(e.target.value)}
className="px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
>
@@ -485,7 +484,7 @@ export default function KiloToolCard({
onApply: handleManualConfig,
currentConfig: {
model: selectedModel,
apiKey: apiKeys?.find((k) => k.id === selectedApiKeyId)?.key || "",
apiKey: apiKeys?.find((k) => k.id === effectiveApiKeyId)?.key || "",
baseUrl: customBaseUrl || baseUrl,
},
} as any)}

View File

@@ -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";
@@ -57,22 +57,12 @@ export default function OpenClawToolCard({
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 && !openclawStatus) {
checkOpenclawStatus();
fetchModelAliases();
fetchBackups();
}
}, [isExpanded, openclawStatus]);
const fetchModelAliases = async () => {
const fetchModelAliases = useCallback(async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
@@ -80,46 +70,66 @@ export default function OpenClawToolCard({
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
}, []);
useEffect(() => {
if (openclawStatus?.installed && !hasInitializedModel.current) {
hasInitializedModel.current = true;
const provider = openclawStatus.settings?.models?.providers?.["omniroute"];
if (provider) {
const primaryModel = openclawStatus.settings?.agents?.defaults?.model?.primary;
if (primaryModel) {
const modelId = primaryModel.replace("omniroute/", "");
setSelectedModel(modelId);
}
// (#523) Keys from /api/keys are masked (first 8 + "****" + last 4).
// Match by prefix/suffix instead of exact comparison.
// apiKey may be a structured secret reference (object) rather than a
// plaintext string, e.g. OpenClaw SecretRefs. Only match on strings.
if (typeof provider.apiKey === "string" && provider.apiKey) {
const fileKeyPrefix = provider.apiKey.slice(0, 8);
const fileKeySuffix = provider.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=openclaw");
const data = await res.json();
if (res.ok) setBackups(data.backups || []);
} catch (error) {
console.log("Error fetching backups:", error);
}
}, [openclawStatus, apiKeys]);
}, []);
const checkOpenclawStatus = async () => {
const checkOpenclawStatus = useCallback(async () => {
setCheckingOpenclaw(true);
try {
const res = await fetch("/api/cli-tools/openclaw-settings");
const data = await res.json();
setOpenclawStatus(data);
// One-time form initialization from the settings file, right after the
// fetch resolves (was a separate openclawStatus effect — moved here so
// no setState runs synchronously inside an effect body).
if (data?.installed && !hasInitializedModel.current) {
hasInitializedModel.current = true;
const provider = data.settings?.models?.providers?.["omniroute"];
if (provider) {
const primaryModel = data.settings?.agents?.defaults?.model?.primary;
if (primaryModel) {
const modelId = primaryModel.replace("omniroute/", "");
setSelectedModel(modelId);
}
// (#523) Keys from /api/keys are masked (first 8 + "****" + last 4).
// Match by prefix/suffix instead of exact comparison.
// apiKey may be a structured secret reference (object) rather than a
// plaintext string, e.g. OpenClaw SecretRefs. Only match on strings.
if (typeof provider.apiKey === "string" && provider.apiKey) {
const fileKeyPrefix = provider.apiKey.slice(0, 8);
const fileKeySuffix = provider.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) {
setOpenclawStatus({ installed: false, error: error.message });
} finally {
setCheckingOpenclaw(false);
}
};
}, [apiKeys]);
useEffect(() => {
if (!(isExpanded && !openclawStatus)) 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([checkOpenclawStatus(), fetchModelAliases(), fetchBackups()]);
})();
}, [isExpanded, openclawStatus, checkOpenclawStatus, fetchModelAliases, fetchBackups]);
const getEffectiveBaseUrl = () => {
const url = customBaseUrl || baseUrl;
@@ -137,7 +147,7 @@ export default function OpenClawToolCard({
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/openclaw-settings", {
method: "POST",
@@ -199,17 +209,6 @@ export default function OpenClawToolCard({
setModalOpen(false);
};
// ── Backups ──
const fetchBackups = async () => {
try {
const res = await fetch("/api/cli-tools/backups?tool=openclaw");
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);
@@ -241,7 +240,7 @@ export default function OpenClawToolCard({
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" : "<API_KEY_FROM_DASHBOARD>");
@@ -408,7 +407,7 @@ export default function OpenClawToolCard({
</span>
{apiKeys.length > 0 ? (
<select
value={selectedApiKeyId}
value={effectiveApiKeyId}
onChange={(e) => 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"
>