mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-31 03:22:19 +03:00
Compare commits
1 Commits
release/v3
...
chore/1214
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6b7c51321 |
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user