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
31 changed files with 917 additions and 1260 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": {
@@ -1298,21 +1216,64 @@
"count": 3
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx": {
"react-hooks/refs": {
"count": 4
},
"react-hooks/set-state-in-effect": {
"count": 3
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 3
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderCcAliasSection.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderInterceptionSection.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderParamFilterSection.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/VolcengineConnectModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/CursorAgentNudge.test.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/ImportCodexAuthModal.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -1331,11 +1292,57 @@
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/hooks/useProviderModels.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/hooks/useProviderUrlFilters.ts": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/providers/hooks/useRiskAcknowledged.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/page.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 4
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/providers/services/components/CliproxyAccountHealthCard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/services/components/DarioAccountPanel.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/utils/buildCurl.ts": {

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

View File

@@ -91,25 +91,6 @@ function parseContextWindowOverrideInput(raw: string): { value: number | null; i
return { value: Number(trimmed), invalid: false };
}
// Fetch + parse extracted from the component so errors surface as a return
// value (logged here) instead of state writes inside catch/finally blocks —
// the load callback then only sets state after the await, which lets the
// mount effect call it without a synchronous setState.
async function fetchProviderModelsPayload(providerId: string): Promise<{
models: CompatModelRow[];
overrides: Array<CompatModelRow & { id: string }>;
} | null> {
try {
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`);
if (!res.ok) return null;
const data = await res.json();
return { models: data.models || [], overrides: data.modelCompatOverrides || [] };
} catch (e) {
console.error("Failed to fetch custom models:", e);
return null;
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@@ -160,28 +141,23 @@ export default function CustomModelsSection({
const syncedModelIdSet = useMemo(() => new Set(syncedModelIds), [syncedModelIds]);
const fetchCustomModels = useCallback(async () => {
const payload = await fetchProviderModelsPayload(providerId);
if (payload) {
setCustomModels(payload.models);
setModelCompatOverrides(payload.overrides);
try {
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`);
if (res.ok) {
const data = await res.json();
setCustomModels(data.models || []);
setModelCompatOverrides(data.modelCompatOverrides || []);
}
} catch (e) {
console.error("Failed to fetch custom models:", e);
} finally {
setLoading(false);
}
setLoading(false);
}, [providerId]);
// Initial load: the async work is defined INSIDE the effect (calling the
// component-scope fetchCustomModels callback synchronously from an effect is
// rejected by the compiler rules); every setState here runs after the await.
useEffect(() => {
const run = async () => {
const payload = await fetchProviderModelsPayload(providerId);
if (payload) {
setCustomModels(payload.models);
setModelCompatOverrides(payload.overrides);
}
setLoading(false);
};
void run();
}, [providerId]);
fetchCustomModels();
}, [fetchCustomModels]);
const handleAdd = async () => {
if (!newModelId.trim() || adding) return;
@@ -564,8 +540,8 @@ export default function CustomModelsSection({
FREE
</label>
</div>
</div>
</div>
</div>
{/* List */}
{loading ? (

View File

@@ -168,10 +168,8 @@ export default function ModelCompatPopover({
width: number;
} | null>(null);
const headerRowIdRef = useRef(0);
// Mirror of headerRows, kept in sync by applyHeaderRows below (every state
// write goes through it), so blur/close commits read the freshest rows
// without touching the ref during render.
const headerRowsRef = useRef<HeaderDraftRow[]>([]);
headerRowsRef.current = headerRows;
// Param-filter drafts are mirrored into a ref so the close/unmount save path reads the
// latest typed values instead of the values captured when the handler was created (#8910).
@@ -192,18 +190,13 @@ export default function ModelCompatPopover({
providerId,
modelId,
});
// Mirrored in an effect (never during render): the ref is only read from
// event handlers and the save path, which run after this effect committed.
useEffect(() => {
paramTargetRef.current = { key: paramTargetKey, providerId, modelId };
}, [paramTargetKey, providerId, modelId]);
paramTargetRef.current = { key: paramTargetKey, providerId, modelId };
// Mirrors of the displayed text, so an edit can snapshot both fields
// synchronously. applyParamFields below is the ONLY writer of
// blockText/allowText and keeps these refs in sync itself, so no render-time
// mirroring is needed (and none is allowed by the compiler's refs rule).
// Mirrors of the displayed text, so an edit can snapshot both fields synchronously.
const blockTextRef = useRef("");
const allowTextRef = useRef("");
blockTextRef.current = blockText;
allowTextRef.current = allowText;
// Which target the values currently in the fields belong to. Guards the invariant that
// blockTextRef/allowTextRef never hold content belonging to a target other than the one being
// displayed — the desync that let one model's server values be saved under another (#8910).
@@ -280,38 +273,14 @@ export default function ModelCompatPopover({
};
}, [open, tryCommitHeaderRows]);
// Rows (re)load from the parent when the popover opens or the protocol
// switches — both user gestures — so the load lives in those handlers
// (handleToggleOpen / handleProtocolChange) instead of an effect. This keeps
// the old guarantee: a new inline parent callback on a re-render never wipes
// in-progress edits.
const applyHeaderRows = (rows: HeaderDraftRow[]) => {
headerRowsRef.current = rows;
setHeaderRows(rows);
};
const loadHeaderRowsFor = (nextProtocol: string) => {
const rec = getUpstreamHeadersRecord(nextProtocol);
applyHeaderRows(recordToHeaderRows(rec, genHeaderRowId));
};
const resetValueVisibility = () => {
setValuePeekRowId(null);
setValueFocusRowId(null);
};
const handleToggleOpen = () => {
const next = !open;
setOpen(next);
resetValueVisibility();
if (next) loadHeaderRowsFor(protocol);
};
const handleProtocolChange = (nextProtocol: string) => {
setProtocol(nextProtocol);
resetValueVisibility();
if (open) loadHeaderRowsFor(nextProtocol);
};
useEffect(() => {
if (!open) return;
const rec = getUpstreamHeadersRecord(protocol);
setHeaderRows(recordToHeaderRows(rec, genHeaderRowId));
// Only re-load rows when opening or switching protocol — not when the parent passes a new
// inline callback every render (would wipe in-progress edits).
// eslint-disable-next-line react-hooks/exhaustive-deps -- see above
}, [open, protocol]);
// Load model-level block/allow from param-filters API
useEffect(() => {
@@ -451,23 +420,30 @@ export default function ModelCompatPopover({
};
}, [open, paramTargetKey, saveModelParamFilters]);
useEffect(() => {
setValuePeekRowId(null);
setValueFocusRowId(null);
}, [open, protocol]);
const namedHeaderCount = headerRows.filter((r) => r.name.trim()).length;
const canAddHeaderRow = namedHeaderCount < UPSTREAM_HEADERS_UI_MAX;
const updateHeaderRow = (id: string, patch: Partial<Pick<HeaderDraftRow, "name" | "value">>) => {
applyHeaderRows(headerRowsRef.current.map((r) => (r.id === id ? { ...r, ...patch } : r)));
setHeaderRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
};
const addHeaderRow = () => {
if (!canAddHeaderRow) return;
applyHeaderRows([...headerRowsRef.current, { id: genHeaderRowId(), name: "", value: "" }]);
setHeaderRows((prev) => [...prev, { id: genHeaderRowId(), name: "", value: "" }]);
};
const removeHeaderRow = (id: string) => {
const next = headerRowsRef.current.filter((r) => r.id !== id);
const normalized = next.length === 0 ? [{ id: genHeaderRowId(), name: "", value: "" }] : next;
applyHeaderRows(normalized);
queueMicrotask(() => tryCommitHeaderRows(normalized));
setHeaderRows((prev) => {
const next = prev.filter((r) => r.id !== id);
const normalized = next.length === 0 ? [{ id: genHeaderRowId(), name: "", value: "" }] : next;
queueMicrotask(() => tryCommitHeaderRows(normalized));
return normalized;
});
};
useEffect(() => {
@@ -476,11 +452,7 @@ export default function ModelCompatPopover({
const target = e.target as Node;
const insideTrigger = ref.current?.contains(target);
const insidePanel = panelRef.current?.contains(target);
if (!insideTrigger && !insidePanel) {
setOpen(false);
setValuePeekRowId(null);
setValueFocusRowId(null);
}
if (!insideTrigger && !insidePanel) setOpen(false);
};
document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener("mousedown", onDocClick);
@@ -506,10 +478,10 @@ export default function ModelCompatPopover({
}, [open]);
useLayoutEffect(() => {
// No rect reset on close: the portal render is gated on `open`, and
// reopening recomputes the rect below before the browser paints, so a
// stale rect is never visible.
if (!open) return;
if (!open) {
setPortalPanelRect(null);
return;
}
updatePortalPanelRect();
window.addEventListener("resize", updatePortalPanelRect);
window.addEventListener("scroll", updatePortalPanelRect, true);
@@ -526,7 +498,7 @@ export default function ModelCompatPopover({
<div className="relative inline-flex" ref={ref}>
<button
type="button"
onClick={handleToggleOpen}
onClick={() => setOpen((v) => !v)}
disabled={disabled}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg border border-border bg-background text-text-muted hover:bg-muted hover:text-text-main disabled:opacity-50 transition-colors"
title={t("compatAdjustmentsTitle")}
@@ -563,7 +535,7 @@ export default function ModelCompatPopover({
</label>
<select
value={protocol}
onChange={(e) => handleProtocolChange(e.target.value)}
onChange={(e) => setProtocol(e.target.value)}
disabled={disabled}
className="mb-4 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-2 text-xs text-text-main focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
>

View File

@@ -77,47 +77,31 @@ function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
// Wraps the GET so a failure comes back as a value: the load callback then only
// sets state after the await (no synchronous setState reachable from the effect).
async function fetchCcAliasStateSafe(
providerId: string
): Promise<{ ok: boolean; state?: CcAliasState; error?: string }> {
try {
return { ok: true, state: await fetchCcAliasState(providerId) };
} catch (err) {
return { ok: false, error: errorMessage(err) };
}
}
/** Loads the provider's alias settings once and reports a load failure to the operator. */
function useCcAliasData(providerId: string, t: ProviderMessageTranslator) {
const notify = useNotificationStore();
const [state, setState] = useState<CcAliasState>(DEFAULT_STATE);
// Loading is derived: true until a load attempt for the CURRENT provider
// settles — this also re-shows the skeleton when providerId changes.
const [loadedProviderId, setLoadedProviderId] = useState<string | null>(null);
const loading = loadedProviderId !== providerId;
const [loading, setLoading] = useState(true);
// The async work is defined INSIDE the effect (a component-scope loader
// called synchronously from an effect is rejected by the compiler rules);
// every setState here runs after the await.
useEffect(() => {
const run = async () => {
const outcome = await fetchCcAliasStateSafe(providerId);
if (outcome.ok) {
setState(outcome.state);
} else {
notify.error(
providerText(t, "ccAliasLoadError", "Failed to load discovery-alias settings: {error}", {
error: outcome.error,
})
);
}
setLoadedProviderId(providerId);
};
void run();
const loadState = useCallback(async () => {
setLoading(true);
try {
setState(await fetchCcAliasState(providerId));
} catch (err) {
notify.error(
providerText(t, "ccAliasLoadError", "Failed to load discovery-alias settings: {error}", {
error: errorMessage(err),
})
);
} finally {
setLoading(false);
}
}, [providerId, notify, t]);
useEffect(() => {
loadState();
}, [loadState]);
return { state, setState, loading };
}

View File

@@ -63,43 +63,27 @@ function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
// Wraps the GET so a failure comes back as a value: the load callback then only
// sets state after the await (no synchronous setState reachable from the effect).
async function fetchInterceptionTogglesSafe(
providerId: string
): Promise<{ ok: boolean; toggles?: InterceptionToggles; error?: string }> {
try {
return { ok: true, toggles: await fetchInterceptionToggles(providerId) };
} catch (err) {
return { ok: false, error: errorMessage(err) };
}
}
function useProviderInterceptionToggles(providerId: string, t: Translate) {
const notify = useNotificationStore();
const [toggles, setToggles] = useState<InterceptionToggles>(DEFAULT_TOGGLES);
// Loading is derived: true until a load attempt for the CURRENT provider
// settles — this also re-shows the skeleton when providerId changes.
const [loadedProviderId, setLoadedProviderId] = useState<string | null>(null);
const loading = loadedProviderId !== providerId;
const [loading, setLoading] = useState(true);
const [savingKey, setSavingKey] = useState<keyof InterceptionToggles | null>(null);
// The async work is defined INSIDE the effect (a component-scope loader
// called synchronously from an effect is rejected by the compiler rules);
// every setState here runs after the await.
useEffect(() => {
const run = async () => {
const outcome = await fetchInterceptionTogglesSafe(providerId);
if (outcome.ok) {
setToggles(outcome.toggles);
} else {
notify.error(t("interceptionLoadError", { error: outcome.error }));
}
setLoadedProviderId(providerId);
};
void run();
const loadToggles = useCallback(async () => {
setLoading(true);
try {
setToggles(await fetchInterceptionToggles(providerId));
} catch (err) {
notify.error(t("interceptionLoadError", { error: errorMessage(err) }));
} finally {
setLoading(false);
}
}, [providerId, notify, t]);
useEffect(() => {
loadToggles();
}, [loadToggles]);
const handleToggle = useCallback(
async (key: keyof InterceptionToggles, value: boolean) => {
const next = { ...toggles, [key]: value };
@@ -146,7 +130,9 @@ export default function ProviderInterceptionSection({
<h2 className="text-base font-semibold text-text-main mb-1">
{t("interceptionSectionTitle")}
</h2>
<p className="text-xs text-text-muted mb-4 leading-relaxed">{t("interceptionSectionHint")}</p>
<p className="text-xs text-text-muted mb-4 leading-relaxed">
{t("interceptionSectionHint")}
</p>
<div className="flex flex-col gap-4">
<Toggle
size="sm"

View File

@@ -74,18 +74,6 @@ function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
// Wraps the GET so a failure comes back as a value: the load callback then only
// sets state after the await (no synchronous setState reachable from the effect).
async function fetchParamFilterConfigSafe(
providerId: string
): Promise<{ ok: boolean; config?: ParamFilterConfig; error?: string }> {
try {
return { ok: true, config: await fetchParamFilterConfig(providerId) };
} catch (err) {
return { ok: false, error: errorMessage(err) };
}
}
// ---------------------------------------------------------------------------
// State hook — owns config load/save/reset so the component body stays JSX-only.
// ---------------------------------------------------------------------------
@@ -107,10 +95,7 @@ function useDirtySetter<T>(setValue: (value: T) => void, setDirty: (value: boole
function useProviderParamFilterConfig(providerId: string, t: Translate) {
const notify = useNotificationStore();
const [, setConfig] = useState<ParamFilterConfig>({ block: [], allow: [], autoLearn: false });
// Loading is derived: true until a load attempt for the CURRENT provider
// settles — this also re-shows the skeleton when providerId changes.
const [loadedProviderId, setLoadedProviderId] = useState<string | null>(null);
const loading = loadedProviderId !== providerId;
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [blockText, setBlockTextState] = useState("");
@@ -121,25 +106,25 @@ function useProviderParamFilterConfig(providerId: string, t: Translate) {
const setAllowText = useDirtySetter(setAllowTextState, setDirty);
const setAutoLearn = useDirtySetter(setAutoLearnState, setDirty);
// The async work is defined INSIDE the effect (a component-scope loader
// called synchronously from an effect is rejected by the compiler rules);
// every setState here runs after the await.
useEffect(() => {
const run = async () => {
const outcome = await fetchParamFilterConfigSafe(providerId);
if (outcome.ok) {
setConfig(outcome.config);
setBlockTextState(formatCommaList(outcome.config.block));
setAllowTextState(formatCommaList(outcome.config.allow));
setAutoLearnState(outcome.config.autoLearn);
} else {
notify.notify(t("paramFiltersLoadError", { error: outcome.error }), "error");
}
setLoadedProviderId(providerId);
};
void run();
const loadConfig = useCallback(async () => {
setLoading(true);
try {
const cfg = await fetchParamFilterConfig(providerId);
setConfig(cfg);
setBlockTextState(formatCommaList(cfg.block));
setAllowTextState(formatCommaList(cfg.allow));
setAutoLearnState(cfg.autoLearn);
} catch (err) {
notify.notify(t("paramFiltersLoadError", { error: errorMessage(err) }), "error");
} finally {
setLoading(false);
}
}, [providerId, notify, t]);
useEffect(() => {
loadConfig();
}, [loadConfig]);
const handleSave = useCallback(async () => {
setSaving(true);
try {

View File

@@ -88,16 +88,7 @@ export default function VolcengineConnectModal({
notify,
t,
}: VolcengineConnectModalProps) {
// Prefilled from the last successful login via a lazy initializer — reading
// localStorage inside the open effect required a synchronous setState there.
const [phone, setPhone] = useState(() => {
if (typeof window === "undefined") return "";
try {
return localStorage.getItem(PHONE_STORAGE_KEY) ?? "";
} catch {
return "";
}
});
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [captcha, setCaptcha] = useState("");
const [session, setSession] = useState<SessionView | null>(null);
@@ -108,12 +99,6 @@ export default function VolcengineConnectModal({
const [resendCountdown, setResendCountdown] = useState(0);
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
// Latest session, mirrored for the close/unmount cleanup below — that effect
// only depends on isOpen, so reading the state directly would be stale.
const sessionRef = useRef<SessionView | null>(null);
useEffect(() => {
sessionRef.current = session;
}, [session]);
// ── lifecycle ────────────────────────────────────────────────────────────
@@ -132,35 +117,22 @@ export default function VolcengineConnectModal({
setResendCountdown(0);
}, [stopTimers]);
// Leaving the modal cancels an in-flight session server-side and stops
// polling. Runs as the cleanup of this open-scoped effect (no setState here).
useEffect(() => {
if (!isOpen) return;
return () => {
const current = sessionRef.current;
const active = current && !isTerminal(current.phase) ? current : null;
if (!isOpen) {
// Leaving the modal cancels an in-flight session server-side.
const active = session && !isTerminal(session.phase) ? session : null;
if (active) {
void fetch(`/api/providers/volcengine-plan/connect/${active.sessionId}/cancel`, {
method: "POST",
}).catch(() => {});
}
stopTimers();
};
}, [isOpen, stopTimers]);
// Local state reset on close — a render-phase adjustment guarded by the
// previous isOpen value (react.dev "adjusting state when a prop changes")
// instead of a synchronous setState inside an effect.
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen);
if (!isOpen) {
setSession(null);
setCode("");
setCaptcha("");
setResendCountdown(0);
reset();
return;
}
}
const saved = typeof window !== "undefined" ? localStorage.getItem(PHONE_STORAGE_KEY) : null;
if (saved) setPhone(saved);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
useEffect(() => stopTimers, [stopTimers]);

View File

@@ -1,5 +1,5 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button, Badge, Input, Modal, Select, Toggle } from "@/shared/components";
import { isValidProviderIconUrl } from "@/shared/validation/iconUrl";
@@ -62,23 +62,8 @@ export default function EditCompatibleNodeModal({
const [iconUrlError, setIconUrlError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
// Modal-open form initialization from the node being edited — applied as a
// render-phase adjustment guarded by the previously initialized node
// (react.dev "adjusting state when a prop changes") instead of a
// synchronous-setState effect. Closing clears the marker so the next open
// re-initializes again.
const [initializedFor, setInitializedFor] = useState<{
node: EditCompatibleNodeModalNode;
isAnthropic?: boolean;
isCcCompatible?: boolean;
} | null>(null);
if (isOpen && node) {
if (
initializedFor?.node !== node ||
initializedFor.isAnthropic !== isAnthropic ||
initializedFor.isCcCompatible !== isCcCompatible
) {
setInitializedFor({ node, isAnthropic, isCcCompatible });
useEffect(() => {
if (isOpen && node) {
const psd = (node.providerSpecificData || {}) as Record<string, unknown>;
setFormData({
name: node.name || "",
@@ -109,9 +94,7 @@ export default function EditCompatibleNodeModal({
)
);
}
} else if (initializedFor !== null) {
setInitializedFor(null);
}
}, [isOpen, node, isAnthropic, isCcCompatible]);
const apiTypeOptions = [
{ value: "chat", label: t("chatCompletions") },

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button, Badge, Input, Modal, Toggle, Select } from "@/shared/components";
import {
@@ -262,19 +262,8 @@ export default function EditConnectionModal({
: apiKeyOptional
? t("apiKeyOptionalHint")
: t("leaveBlankKeepCurrentApiKey");
// Modal-open form initialization from the loaded connection — applied as a
// render-phase adjustment guarded by the previously initialized connection
// (react.dev "adjusting state when a prop changes") instead of the former
// synchronous-setState effect. Remounting the 30+ field form per connection
// id stays out of scope (#11251 follow-up, #9985); closing clears the marker
// so the next open re-initializes again.
const [initializedFor, setInitializedFor] = useState<{
connection: EditConnectionModalConnection;
providerId: string;
} | null>(null);
if (isOpen && connection) {
if (initializedFor?.connection !== connection || initializedFor.providerId !== providerId) {
setInitializedFor({ connection, providerId });
useEffect(() => {
if (isOpen && connection) {
const effectiveProvider = connection.provider || providerId;
const existingBaseUrl = stringField(connection.providerSpecificData?.baseUrl);
const existingTargetFormat = stringField(connection.providerSpecificData?.targetFormat);
@@ -304,6 +293,13 @@ export default function EditConnectionModal({
connection.providerSpecificData?.quotaPerUnit != null
? String(connection.providerSpecificData.quotaPerUnit)
: "";
// Modal-open form initialization from the loaded connection (sync with an
// external system on `isOpen`); remounting the 30+ field form per
// connection id is a behavior-risking restructure out of scope here
// (#11251 follow-up, #9985).
// NOTE: no react-hooks/set-state-in-effect suppression needed — the rule
// only fires on unconditional synchronous setState, and this one is
// guarded by the isOpen/connection condition above.
setFormData({
name: connection.name || "",
priority: connection.priority || 1,
@@ -441,9 +437,15 @@ export default function EditConnectionModal({
setValidatedProviderSpecificData(undefined);
setSaveError(null);
}
} else if (initializedFor !== null) {
setInitializedFor(null);
}
}, [
isOpen,
connection,
providerId,
defaultBaseUrl,
showsRegion,
defaultRegion,
setOpenRouterPreset,
]);
const handleTest = async () => {
if (!provider) return;
setTesting(true);

View File

@@ -42,95 +42,6 @@ import {
const MAX_BULK_IDS = 100;
const PAGE_SIZE = 50;
// ──── module-level fetch helpers ────────────────────────────────────────────
// The network/parse/retry concerns live outside the hook so the callbacks
// below only set state after the await — the mount effect can then call them
// without a synchronous setState (errors come back as values, not as state
// writes inside catch/finally blocks).
interface ProviderConnectionsFetchResult {
connections: ConnectionRowConnection[] | null;
node: any;
nodeResolved: boolean;
}
async function loadProviderConnectionsData(
providerId: string,
isCompatible: boolean
): Promise<ProviderConnectionsFetchResult | null> {
try {
const connectionsUrl = getProviderConnectionsRequestUrl(providerId);
const [connectionsRes, nodesRes] = await Promise.all([
fetch(connectionsUrl, { cache: "no-store" }),
fetch("/api/provider-nodes", { cache: "no-store" }),
]);
const connectionsData = await connectionsRes.json();
const nodesData = await nodesRes.json();
const connections = connectionsRes.ok
? (connectionsData.connections || []).filter((c: any) =>
connectionBelongsToProviderPage(c.provider, providerId)
)
: null;
let node = null;
let nodeResolved = false;
if (nodesRes.ok) {
nodeResolved = true;
node = (nodesData.nodes || []).find((entry: any) => entry.id === providerId) || null;
// Newly created compatible nodes can be briefly unavailable on one worker.
if (!node && isCompatible) {
for (let attempt = 0; attempt < 3; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 150));
const retryRes = await fetch("/api/provider-nodes", { cache: "no-store" });
if (!retryRes.ok) continue;
const retryData = await retryRes.json();
node = (retryData.nodes || []).find((entry: any) => entry.id === providerId) || null;
if (node) break;
}
}
}
return { connections, node, nodeResolved };
} catch (error) {
console.log("Error fetching connections:", error);
return null;
}
}
async function loadProxyConfigData(): Promise<{ config: any } | null> {
try {
const res = await fetch("/api/settings/proxy", { cache: "no-store" });
if (res.ok) return { config: await res.json() };
return { config: null };
} catch {
// Proxy indicators are best-effort — keep whatever is currently shown.
return null;
}
}
async function resolveConnectionProxies(
conns: { id?: string }[]
): Promise<Record<string, { proxy: any; level: string } | null> | null> {
try {
const results = await Promise.all(
conns
.filter((c) => c.id)
.map((c) =>
fetch(`/api/settings/proxy?resolve=${encodeURIComponent(c.id!)}`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
.then((data) => [c.id!, data] as [string, any])
.catch(() => [c.id!, null] as [string, any])
)
);
const map: Record<string, { proxy: any; level: string } | null> = {};
for (const [id, data] of results) {
map[id] = data?.proxy ? data : null;
}
return map;
} catch {
return null;
}
}
// ──── types ─────────────────────────────────────────────────────────────────
/**
@@ -299,50 +210,93 @@ export function useProviderConnections(
// ────────────────────────────────────────────────────────────────────────
const fetchProxyConfig = useCallback(async () => {
const result = await loadProxyConfigData();
if (result) setProxyConfig(result.config);
try {
const res = await fetch("/api/settings/proxy", { cache: "no-store" });
if (res.ok) {
setProxyConfig(await res.json());
} else {
setProxyConfig(null);
}
} catch {
// Proxy indicators are best-effort.
}
}, []);
const fetchConnections = useCallback(async () => {
const result = await loadProviderConnectionsData(providerId, isCompatible);
if (result) {
if (result.connections) setConnections(result.connections);
if (result.nodeResolved) setProviderNode(result.node);
try {
const connectionsUrl = getProviderConnectionsRequestUrl(providerId);
const [connectionsRes, nodesRes] = await Promise.all([
fetch(connectionsUrl, { cache: "no-store" }),
fetch("/api/provider-nodes", { cache: "no-store" }),
]);
const connectionsData = await connectionsRes.json();
const nodesData = await nodesRes.json();
if (connectionsRes.ok) {
const filtered = (connectionsData.connections || []).filter((c: any) =>
connectionBelongsToProviderPage(c.provider, providerId)
);
setConnections(filtered);
}
if (nodesRes.ok) {
let node = (nodesData.nodes || []).find((entry: any) => entry.id === providerId) || null;
// Newly created compatible nodes can be briefly unavailable on one worker.
if (!node && isCompatible) {
for (let attempt = 0; attempt < 3; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 150));
const retryRes = await fetch("/api/provider-nodes", { cache: "no-store" });
if (!retryRes.ok) continue;
const retryData = await retryRes.json();
node = (retryData.nodes || []).find((entry: any) => entry.id === providerId) || null;
if (node) break;
}
}
setProviderNode(node);
}
} catch (error) {
console.log("Error fetching connections:", error);
} finally {
setLoading(false);
}
setLoading(false);
}, [providerId, isCompatible]);
const loadConnProxies = useCallback(async (conns: { id?: string }[]) => {
if (!conns.length) return;
try {
const results = await Promise.all(
conns
.filter((c) => c.id)
.map((c) =>
fetch(`/api/settings/proxy?resolve=${encodeURIComponent(c.id!)}`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
.then((data) => [c.id!, data] as [string, any])
.catch(() => [c.id!, null] as [string, any])
)
);
const map: Record<string, { proxy: any; level: string } | null> = {};
for (const [id, data] of results) {
map[id] = data?.proxy ? data : null;
}
setConnProxyMap(map);
} catch {
// ignore
}
}, []);
// ── effects ──────────────────────────────────────────────────────────────
// The async work is defined INSIDE each effect (a component-scope loader
// called synchronously from an effect is rejected by the compiler rules);
// every setState below runs after an await.
useEffect(() => {
const run = async () => {
const result = await loadProviderConnectionsData(providerId, isCompatible);
if (result) {
if (result.connections) setConnections(result.connections);
if (result.nodeResolved) setProviderNode(result.node);
}
setLoading(false);
};
void run();
const runProxyConfig = async () => {
const result = await loadProxyConfigData();
if (result) setProxyConfig(result.config);
};
void runProxyConfig();
}, [providerId, isCompatible]);
fetchConnections();
void fetchProxyConfig();
}, [fetchConnections, fetchProxyConfig]);
// Per-connection proxy (handles registry assignments)
useEffect(() => {
if (loading || connections.length === 0) return;
const run = async () => {
const map = await resolveConnectionProxies(connections);
if (map) setConnProxyMap(map);
};
void run();
}, [loading, connections]);
if (!loading && connections.length > 0) {
void loadConnProxies(connections);
}
}, [loading, connections, loadConnProxies]);
// Upstream proxy routing config (native / CLIProxyAPI / Dario / fallback)
useEffect(() => {
@@ -592,11 +546,7 @@ export function useProviderConnections(
const data = await res.json().catch(() => ({}));
notify.error(
data.error ||
providerText(
t,
"failedUpdateCliproxyRouting",
"Failed to update upstream proxy routing"
)
providerText(t, "failedUpdateCliproxyRouting", "Failed to update upstream proxy routing")
);
return;
}

View File

@@ -26,32 +26,6 @@ import {
providerText,
} from "../providerPageHelpers";
// Shared /api/settings fetch with error-as-value semantics so the loaders
// below only touch state after the await (no synchronous setState reachable
// from the load effects).
async function fetchSettingsPayload(): Promise<{
ok: boolean;
data?: Record<string, unknown>;
message?: string;
}> {
try {
const response = await fetch("/api/settings", { cache: "no-store" });
if (!response.ok) {
throw new Error(`Settings request failed with HTTP ${response.status}`);
}
const data = await response.json();
if (!data || typeof data !== "object") {
throw new Error("Settings response was empty");
}
return { ok: true, data };
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : "Failed to load settings",
};
}
}
// ──── types ─────────────────────────────────────────────────────────────────
export interface UseProviderSettingsReturn {
@@ -99,19 +73,6 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
>(null);
const [savingClaudeRoutingPreference, setSavingClaudeRoutingPreference] = useState(false);
// Reset the per-provider load flags when the provider changes — a
// render-phase adjustment guarded by the previous providerId (react.dev
// "adjusting state when a prop changes"), replacing the synchronous resets
// that used to run inside the load effects.
const [settingsProviderId, setSettingsProviderId] = useState(providerId);
if (settingsProviderId !== providerId) {
setSettingsProviderId(providerId);
setCodexSettingsLoaded(false);
setCodexSettingsLoadError(null);
setClaudeRoutingSettingsLoaded(false);
setClaudeRoutingSettingsLoadError(null);
}
// ── derived ──────────────────────────────────────────────────────────────
const codexGlobalServiceModeOptions = useMemo(
() =>
@@ -128,87 +89,76 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
codexSettingsRequestSeqRef.current = requestSeq;
const isCurrentRequest = () => codexSettingsRequestSeqRef.current === requestSeq;
// Non-codex providers keep the initial false/null flags (also restored by
// the render-phase reset above when providerId changes).
if (providerId !== "codex") return;
const outcome = await fetchSettingsPayload();
if (!isCurrentRequest()) return;
if (!outcome.ok) {
if (providerId !== "codex") {
setCodexSettingsLoaded(false);
setCodexSettingsLoadError(outcome.message);
setCodexSettingsLoadError(null);
return;
}
const resolvedCodexServiceTier = resolveCodexGlobalFastServiceTier(outcome.data);
setCodexGlobalServiceMode(getCodexGlobalServiceMode(outcome.data));
setCodexGlobalSupportedModels([...resolvedCodexServiceTier.supportedModels]);
setCodexSettingsLoaded(false);
setCodexSettingsLoadError(null);
setCodexSettingsLoaded(true);
try {
const response = await fetch("/api/settings", { cache: "no-store" });
if (!response.ok) {
throw new Error(`Settings request failed with HTTP ${response.status}`);
}
const data = await response.json();
if (!data || typeof data !== "object") {
throw new Error("Settings response was empty");
}
if (!isCurrentRequest()) return;
const resolvedCodexServiceTier = resolveCodexGlobalFastServiceTier(data);
setCodexGlobalServiceMode(getCodexGlobalServiceMode(data));
setCodexGlobalSupportedModels([...resolvedCodexServiceTier.supportedModels]);
setCodexSettingsLoaded(true);
} catch (error) {
if (!isCurrentRequest()) return;
setCodexSettingsLoaded(false);
setCodexSettingsLoadError(error instanceof Error ? error.message : "Failed to load settings");
}
}, [providerId]);
// The async work is duplicated INSIDE the effect (calling the exposed
// loadCodexSettings callback synchronously from an effect is rejected by the
// compiler rules); every setState here runs after the await.
useEffect(() => {
if (providerId !== "codex") return;
const requestSeq = codexSettingsRequestSeqRef.current + 1;
codexSettingsRequestSeqRef.current = requestSeq;
const isCurrentRequest = () => codexSettingsRequestSeqRef.current === requestSeq;
const run = async () => {
const outcome = await fetchSettingsPayload();
if (!isCurrentRequest()) return;
if (!outcome.ok) {
setCodexSettingsLoaded(false);
setCodexSettingsLoadError(outcome.message);
return;
}
const resolvedCodexServiceTier = resolveCodexGlobalFastServiceTier(outcome.data);
setCodexGlobalServiceMode(getCodexGlobalServiceMode(outcome.data));
setCodexGlobalSupportedModels([...resolvedCodexServiceTier.supportedModels]);
setCodexSettingsLoadError(null);
setCodexSettingsLoaded(true);
};
void run();
}, [providerId]);
void loadCodexSettings();
}, [loadCodexSettings]);
// ── Claude routing settings loader ───────────────────────────────────────
const loadClaudeRoutingSettings = useCallback(async () => {
// Non-claude providers keep the initial false/null flags (also restored by
// the render-phase reset above when providerId changes).
if (providerId !== "claude") return;
const outcome = await fetchSettingsPayload();
if (!outcome.ok) {
if (providerId !== "claude") {
setClaudeRoutingSettingsLoaded(false);
setClaudeRoutingSettingsLoadError(outcome.message);
setClaudeRoutingSettingsLoadError(null);
return;
}
setPreferClaudeCodeForUnprefixedClaudeModels(
outcome.data.preferClaudeCodeForUnprefixedClaudeModels === true
);
setClaudeRoutingSettingsLoadError(null);
setClaudeRoutingSettingsLoaded(true);
}, [providerId]);
// Same inline-in-effect shape as the codex loader above.
useEffect(() => {
if (providerId !== "claude") return;
const run = async () => {
const outcome = await fetchSettingsPayload();
if (!outcome.ok) {
setClaudeRoutingSettingsLoaded(false);
setClaudeRoutingSettingsLoadError(outcome.message);
return;
setClaudeRoutingSettingsLoaded(false);
setClaudeRoutingSettingsLoadError(null);
try {
const response = await fetch("/api/settings", { cache: "no-store" });
if (!response.ok) {
throw new Error(`Settings request failed with HTTP ${response.status}`);
}
const data = await response.json();
if (!data || typeof data !== "object") {
throw new Error("Settings response was empty");
}
setPreferClaudeCodeForUnprefixedClaudeModels(
outcome.data.preferClaudeCodeForUnprefixedClaudeModels === true
data.preferClaudeCodeForUnprefixedClaudeModels === true
);
setClaudeRoutingSettingsLoadError(null);
setClaudeRoutingSettingsLoaded(true);
};
void run();
} catch (error) {
setClaudeRoutingSettingsLoaded(false);
setClaudeRoutingSettingsLoadError(
error instanceof Error ? error.message : "Failed to load settings"
);
}
}, [providerId]);
useEffect(() => {
void loadClaudeRoutingSettings();
}, [loadClaudeRoutingSettings]);
// ── Codex service mode handler ───────────────────────────────────────────
const handleChangeCodexGlobalServiceMode = async (mode: CodexGlobalServiceMode) => {
if (savingCodexGlobalServiceMode || !codexSettingsLoaded) return;

View File

@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Badge, Button, Input, Modal, Select, Toggle } from "@/shared/components";
@@ -133,25 +133,15 @@ export default function AddCompatibleProviderModal({
[t]
);
// Fresh form on every open (and on a mode switch while open) — applied as a
// render-phase adjustment guarded by the previously initialized mode
// (react.dev "adjusting state when a prop changes") instead of a
// synchronous-setState effect. Closing clears the marker so the next open
// re-initializes again.
const [initializedFor, setInitializedFor] = useState<{ mode: CompatibleMode } | null>(null);
if (isOpen) {
if (initializedFor?.mode !== mode) {
setInitializedFor({ mode });
setFormData(createInitialForm(mode));
setValidationResult(null);
setCheckKey("");
setShowAdvanced(false);
setSaveError(null);
setIconUrlError(null);
}
} else if (initializedFor !== null) {
setInitializedFor(null);
}
useEffect(() => {
if (!isOpen) return;
setFormData(createInitialForm(mode));
setValidationResult(null);
setCheckKey("");
setShowAdvanced(false);
setSaveError(null);
setIconUrlError(null);
}, [isOpen, mode]);
const modalTitle =
title ||

View File

@@ -133,7 +133,10 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
}, [providerId, t]);
useEffect(() => {
if (!providerId) return;
if (!providerId) {
setLoading(false);
return;
}
return load();
}, [providerId, load]);
@@ -149,7 +152,5 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
load();
}, [providerId, load]);
// Without a providerId nothing ever loads, so the exposed loading flag is
// derived instead of being reset synchronously inside the effect above.
return { models, loading: providerId ? loading : false, error, retry };
return { models, loading, error, retry };
}

View File

@@ -51,32 +51,26 @@ export function useProviderUrlFilters({
activeServiceKind,
setActiveServiceKind,
}: UseProviderUrlFiltersArgs): { displayModePreferenceReady: boolean } {
// Snapshot of the stored display-mode preference, read once via a lazy
// initializer (localStorage must not be read during render). After the first
// hydration the URL always carries the mode, so the fallback is mount-only.
const [storedDisplayModePreference] = useState<ProviderDisplayMode>(() =>
readProviderDisplayModePreference()
);
const [hydratedFromParams, setHydratedFromParams] = useState<ReadonlyURLSearchParams | null>(
null
);
const [displayModePreferenceReady, setDisplayModePreferenceReady] = useState(false);
const [filtersHydrated, setFiltersHydrated] = useState(false);
// URL → state hydration as a render-phase adjustment guarded by the
// previously hydrated params object (react.dev "adjusting state when a prop
// changes") — replaces the two synchronous setState effects keyed on
// searchParams, and removes the transient default-state first paint.
if (hydratedFromParams !== searchParams) {
setHydratedFromParams(searchParams);
useEffect(() => {
const urlMode = readProviderFiltersFromUrl(searchParams).displayMode;
setProviderDisplayMode(urlMode ?? readProviderDisplayModePreference());
setDisplayModePreferenceReady(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
useEffect(() => {
const urlFilters = readProviderFiltersFromUrl(searchParams);
setProviderDisplayMode(urlFilters.displayMode ?? storedDisplayModePreference);
setSearchQuery(urlFilters.searchQuery ?? "");
setModelSearchQuery(urlFilters.modelSearchQuery ?? "");
setActiveCategory(urlFilters.category ?? null);
setShowFreeOnly(urlFilters.showFreeOnly ?? false);
setActiveServiceKind(urlFilters.mediaKind ?? null);
}
const displayModePreferenceReady = hydratedFromParams !== null;
const filtersHydrated = displayModePreferenceReady;
setFiltersHydrated(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
useEffect(() => {
if (!filtersHydrated || !displayModePreferenceReady) return;

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
import { useCallback, useEffect, useState } from "react";
export const RISK_ACKNOWLEDGED_STORAGE_KEY = "omniroute-risk-acknowledged";
@@ -53,38 +53,22 @@ export function isRiskAcknowledged(providerId: string): boolean {
return readRiskAcknowledgedMap()[providerId] === true;
}
// localStorage is a mutable external store, so the hook below subscribes to it
// through useSyncExternalStore instead of mirroring it into component state
// with an effect (which required a synchronous setState on providerId change).
const riskAcknowledgedListeners = new Set<() => void>();
function subscribeToRiskAcknowledged(listener: () => void): () => void {
riskAcknowledgedListeners.add(listener);
return () => {
riskAcknowledgedListeners.delete(listener);
};
}
function emitRiskAcknowledgedChange(): void {
for (const listener of riskAcknowledgedListeners) listener();
}
export function acknowledgeProviderRisk(providerId: string): void {
const map = readRiskAcknowledgedMap();
map[providerId] = true;
writeRiskAcknowledgedMap(map);
emitRiskAcknowledgedChange();
}
export function useRiskAcknowledged(providerId: string) {
const acknowledged = useSyncExternalStore(
subscribeToRiskAcknowledged,
() => isRiskAcknowledged(providerId),
() => false
);
const [acknowledged, setAcknowledged] = useState(() => isRiskAcknowledged(providerId));
useEffect(() => {
setAcknowledged(isRiskAcknowledged(providerId));
}, [providerId]);
const acknowledge = useCallback(() => {
acknowledgeProviderRisk(providerId);
setAcknowledged(true);
}, [providerId]);
return { acknowledged, acknowledge };

View File

@@ -54,7 +54,9 @@ const AddCompatibleProviderModal = dynamic(
import { CategoryDot } from "./components/CategoryDot";
const ImportProvidersFromFileModal = dynamic(
() =>
import("./components/ImportProvidersFromFileModal").then((m) => m.ImportProvidersFromFileModal),
import("./components/ImportProvidersFromFileModal").then(
(m) => m.ImportProvidersFromFileModal
),
{ ssr: false }
);
import NoAuthProvidersSection from "./components/NoAuthProvidersSection";
@@ -190,27 +192,6 @@ function getConnectionErrorTag(connection, t: ProviderMessageTranslator) {
return "ERR";
}
// OAuth-env repair status fetch, extracted so the callback below only sets
// state after the await (errors come back as `null` instead of a setState
// inside the catch block, which the react-hooks compiler rules reject when the
// callback is invoked from an effect).
async function loadOauthEnvRepairStatus(): Promise<{
available: boolean;
missingCount: number;
} | null> {
try {
const res = await fetch("/api/system/env/repair", { cache: "no-store" });
const data = await res.json();
if (!res.ok) return null;
return {
available: Boolean(data.available),
missingCount: Number(data.missingCount || 0),
};
} catch {
return null;
}
}
export default function ProvidersPage() {
const router = useRouter();
const [connections, setConnections] = useState<any[]>([]);
@@ -316,30 +297,33 @@ export default function ProvidersPage() {
writeProviderDisplayModePreference(storedDisplayMode);
}, [connections.length, displayModePreferenceReady, providerDisplayMode, loading]);
// "No connections → fall back to the 'all' view" is a state adjustment
// derived from other state, applied during render (self-invalidating guard,
// converges in one extra pass) instead of a synchronous setState effect.
if (
shouldSyncProviderDisplayMode(displayModePreferenceReady, loading) &&
connections.length === 0 &&
providerDisplayMode === "configured"
) {
setProviderDisplayMode("all");
}
useEffect(() => {
if (!shouldSyncProviderDisplayMode(displayModePreferenceReady, loading)) return;
if (connections.length === 0 && providerDisplayMode === "configured") {
setProviderDisplayMode("all");
}
}, [connections.length, displayModePreferenceReady, providerDisplayMode, loading]);
const fetchOauthEnvRepairStatus = useCallback(async () => {
setOauthEnvRepairStatus(await loadOauthEnvRepairStatus());
try {
const res = await fetch("/api/system/env/repair", { cache: "no-store" });
const data = await res.json();
if (res.ok) {
setOauthEnvRepairStatus({
available: Boolean(data.available),
missingCount: Number(data.missingCount || 0),
});
} else {
setOauthEnvRepairStatus(null);
}
} catch {
setOauthEnvRepairStatus(null);
}
}, []);
// Inline-in-effect (calling the component-scope callback synchronously from
// an effect is rejected by the compiler rules); setState runs after the await.
useEffect(() => {
const run = async () => {
const status = await loadOauthEnvRepairStatus();
setOauthEnvRepairStatus(status);
};
void run();
}, []);
void fetchOauthEnvRepairStatus();
}, [fetchOauthEnvRepairStatus]);
const handleRepairEnv = async () => {
if (!oauthEnvRepairStatus?.available || repairingEnv) return;

View File

@@ -18,11 +18,7 @@ const STATE_LABELS: Record<CliproxyAccountHealthResult["state"], string> = {
};
function AccountRow({ account }: { account: CliproxyAccountHealth }) {
const state = account.disabled
? "Disabled"
: account.unavailable
? "Unavailable"
: account.status;
const state = account.disabled ? "Disabled" : account.unavailable ? "Unavailable" : account.status;
return (
<li className="flex flex-wrap items-center justify-between gap-3 border-t border-border py-3 first:border-t-0">
<div className="min-w-0">
@@ -48,50 +44,33 @@ function AccountRow({ account }: { account: CliproxyAccountHealth }) {
);
}
// Network + parse concerns live outside the component so the load callback only
// sets state after the await (no synchronous setState reachable from the effect).
async function fetchAccountHealth(): Promise<CliproxyAccountHealthResult> {
try {
const response = await fetch("/api/services/cliproxy/accounts", { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch {
return { state: "unreachable", accounts: [], version: null };
}
}
export function CliproxyAccountHealthCard() {
const [result, setResult] = useState<CliproxyAccountHealthResult | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
const next = await fetchAccountHealth();
setResult(next);
setLoading(false);
}, []);
// Inline-in-effect (calling the component-scope `load` callback synchronously
// from an effect is rejected by the compiler rules); setState is post-await.
useEffect(() => {
const run = async () => {
const next = await fetchAccountHealth();
setResult(next);
setLoading(false);
};
void run();
}, []);
const handleRefresh = () => {
setLoading(true);
try {
const response = await fetch("/api/services/cliproxy/accounts", { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setResult(await response.json());
} catch {
setResult({ state: "unreachable", accounts: [], version: null });
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
};
}, [load]);
return (
<Card
title="CLIProxyAPI accounts"
subtitle="Read-only status from the authenticated management API"
action={
<Button variant="secondary" size="sm" onClick={handleRefresh} loading={loading}>
<Button variant="secondary" size="sm" onClick={() => void load()} loading={loading}>
Refresh
</Button>
}
@@ -108,9 +87,7 @@ export function CliproxyAccountHealthCard() {
)
) : (
<p className="text-sm text-text-muted">
{loading && !result
? "Loading account health…"
: STATE_LABELS[result?.state ?? "unreachable"]}
{loading && !result ? "Loading account health…" : STATE_LABELS[result?.state ?? "unreachable"]}
</p>
)}
</Card>

View File

@@ -65,43 +65,9 @@ function formatExpiry(acc: DarioAccount): string {
return "";
}
// Network + parse concerns extracted so the refresh callbacks only set state
// after the await — the mount effect can then call them without a synchronous
// setState (errors come back as values instead of catch-block state writes).
async function fetchDarioAccounts(): Promise<{ accounts: DarioAccount[] } | { error: string }> {
try {
const res = await fetch("/api/services/dario/admin/accounts");
const json = (await res.json().catch(() => null)) as {
accounts?: DarioAccount[];
error?: string;
} | null;
if (!res.ok) {
throw new Error(json?.error || `HTTP ${res.status}`);
}
return { accounts: Array.isArray(json?.accounts) ? json!.accounts : [] };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
async function fetchOmniConnectionsList(): Promise<OmniConnection[] | null> {
try {
const res = await fetch("/api/services/dario/admin/import-from-omniroute");
const json = (await res.json().catch(() => null)) as {
connections?: OmniConnection[];
error?: string;
} | null;
if (!res.ok) return null;
return Array.isArray(json?.connections) ? json!.connections : [];
} catch {
/* non-fatal — import section just stays empty */
return null;
}
}
export function DarioAccountPanel() {
const [accounts, setAccounts] = useState<DarioAccount[]>([]);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState<PendingLogin | null>(null);
@@ -111,48 +77,51 @@ export function DarioAccountPanel() {
const [notice, setNotice] = useState<string | null>(null);
const [omniConnections, setOmniConnections] = useState<OmniConnection[]>([]);
const [omniLoading, setOmniLoading] = useState(true);
const [omniLoading, setOmniLoading] = useState(false);
const [importBusyId, setImportBusyId] = useState<string | null>(null);
const refreshAccounts = useCallback(async () => {
const outcome = await fetchDarioAccounts();
if ("accounts" in outcome) {
setAccounts(outcome.accounts);
setError(null);
} else {
setError(outcome.error);
}
setLoading(false);
}, []);
// Inline-in-effect (calling the component-scope refresh callbacks
// synchronously from an effect is rejected by the compiler rules); every
// setState here runs after an await.
useEffect(() => {
const run = async () => {
const outcome = await fetchDarioAccounts();
if ("accounts" in outcome) {
setAccounts(outcome.accounts);
setError(null);
} else {
setError(outcome.error);
}
setLoading(false);
};
void run();
const runOmni = async () => {
const list = await fetchOmniConnectionsList();
if (list) setOmniConnections(list);
setOmniLoading(false);
};
void runOmni();
}, []);
const handleRefreshAccountsClick = () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/services/dario/admin/accounts");
const json = (await res.json().catch(() => null)) as {
accounts?: DarioAccount[];
error?: string;
} | null;
if (!res.ok) {
throw new Error(json?.error || `HTTP ${res.status}`);
}
setAccounts(Array.isArray(json?.accounts) ? json!.accounts : []);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
const refreshOmniConnections = useCallback(async () => {
setOmniLoading(true);
try {
const res = await fetch("/api/services/dario/admin/import-from-omniroute");
const json = (await res.json().catch(() => null)) as {
connections?: OmniConnection[];
error?: string;
} | null;
if (res.ok) {
setOmniConnections(Array.isArray(json?.connections) ? json!.connections : []);
}
} catch {
/* non-fatal — import section just stays empty */
} finally {
setOmniLoading(false);
}
}, []);
useEffect(() => {
void refreshAccounts();
};
void refreshOmniConnections();
}, [refreshAccounts, refreshOmniConnections]);
async function importFromOmniroute(connectionId: string) {
setImportBusyId(connectionId);
@@ -375,7 +344,7 @@ export function DarioAccountPanel() {
size="sm"
variant="outline"
disabled={loading}
onClick={handleRefreshAccountsClick}
onClick={() => void refreshAccounts()}
>
Refresh
</Button>

View File

@@ -32,22 +32,6 @@ export function paginateModels(
return models.slice(start, start + pageSize);
}
// Fetch + parse extracted from the component so errors surface as a return
// value instead of state mutations inside catch/finally blocks.
async function fetchServiceModels(
refresh: boolean
): Promise<{ ok: boolean; data?: ServiceModel[]; message?: string | null }> {
try {
const url = `/api/services/${NAME}/models${refresh ? "?refresh=true" : ""}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
return { ok: true, data: Array.isArray(body?.data) ? body.data : [] };
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : null };
}
}
// ── Component ─────────────────────────────────────────────────────────────────
export function NinerouterModelList() {
@@ -58,48 +42,35 @@ export function NinerouterModelList() {
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadModels = useCallback(
const fetchModels = useCallback(
async (refresh = false) => {
// All setState calls stay after the await so the mount effect below can
// call this loader without a synchronous setState inside the effect; the
// refresh button sets its spinner flags in its own handler instead.
const outcome = await fetchServiceModels(refresh);
if (outcome.ok) {
setModels(outcome.data);
setPage(1);
setError(null);
if (refresh) {
setRefreshing(true);
} else {
setError(outcome.message ?? t("modelsLoadFailed"));
setLoading(true);
}
setError(null);
try {
const url = `/api/services/${NAME}/models${refresh ? "?refresh=true" : ""}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
const data: ServiceModel[] = Array.isArray(body?.data) ? body.data : [];
setModels(data);
setPage(1);
} catch (err) {
setError(err instanceof Error ? err.message : t("modelsLoadFailed"));
} finally {
setLoading(false);
setRefreshing(false);
}
setLoading(false);
setRefreshing(false);
},
[t]
);
// Inline-in-effect (calling the component-scope loadModels callback
// synchronously from an effect is rejected by the compiler rules); every
// setState here runs after the await.
useEffect(() => {
const run = async () => {
const outcome = await fetchServiceModels(false);
if (outcome.ok) {
setModels(outcome.data);
setPage(1);
setError(null);
} else {
setError(outcome.message ?? t("modelsLoadFailed"));
}
setLoading(false);
};
void run();
}, [t]);
const handleRefresh = () => {
setRefreshing(true);
setError(null);
void loadModels(true);
};
void fetchModels(false);
}, [fetchModels]);
const totalPages = Math.max(1, Math.ceil(models.length / PAGE_SIZE));
const visibleModels = paginateModels(models, page, PAGE_SIZE);
@@ -121,7 +92,7 @@ export function NinerouterModelList() {
<Button
variant="secondary"
size="sm"
onClick={handleRefresh}
onClick={() => fetchModels(true)}
disabled={loading || refreshing}
className="shrink-0"
>