diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 3a1e9a720f..b7675ff94f 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -829,12 +829,6 @@ "src/app/(dashboard)/dashboard/HomePageClient.tsx": { "@typescript-eslint/no-unused-vars": { "count": 2 - }, - "react-hooks/exhaustive-deps": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 2 } }, "src/app/(dashboard)/dashboard/a2a/page.tsx": { @@ -850,62 +844,16 @@ "count": 1 } }, - "src/app/(dashboard)/dashboard/activity/ActivityFeedClient.tsx": { - "react-hooks/purity": { - "count": 1 - }, - "react-hooks/refs": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/analytics/CacheHealthTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 } }, - "src/app/(dashboard)/dashboard/analytics/ProviderUtilizationTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/analytics/RouteExplainabilityTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 2 - } - }, "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 } }, - "src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/audit/McpAuditTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 2 - } - }, "src/app/(dashboard)/dashboard/batch/components/wizard/CostEstimateStep.tsx": { "react-hooks/set-state-in-effect": { "count": 1 @@ -926,24 +874,6 @@ "count": 1 } }, - "src/app/(dashboard)/dashboard/cache/components/CacheEntriesTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/cache/components/ReasoningCacheTab.tsx": { - "react-hooks/purity": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/cache/page.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/app/(dashboard)/dashboard/cli-agents/CliAgentsPageClient.tsx": { "no-restricted-syntax": { "count": 4 @@ -1060,31 +990,6 @@ "count": 2 } }, - "src/app/(dashboard)/dashboard/memory/components/EditMemoryModal.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/memory/hooks/useEngineStatus.ts": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/memory/hooks/useMemorySettings.ts": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/app/(dashboard)/dashboard/onboarding/page.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 @@ -1185,29 +1090,6 @@ "count": 1 } }, - "src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/radar/intel/page.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/radar/page.tsx": { - "react-hooks/set-state-in-effect": { - "count": 2 - } - }, - "src/app/(dashboard)/dashboard/radar/setup/page.tsx": { - "react-hooks/preserve-manual-memoization": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/app/(dashboard)/dashboard/relay/RelayProxyClient.tsx": { "react-hooks/set-state-in-effect": { "count": 1 @@ -1376,11 +1258,6 @@ "count": 1 } }, - "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": { - "react-hooks/set-state-in-effect": { - "count": 2 - } - }, "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.tsx": { "@typescript-eslint/no-unused-vars": { "count": 3 @@ -1391,11 +1268,6 @@ "count": 1 } }, - "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useCodexResetCreditRedemption.ts": { - "react-hooks/immutability": { - "count": 2 - } - }, "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 @@ -1404,17 +1276,11 @@ "src/app/(dashboard)/dashboard/usage/components/RateLimitStatus.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 } }, "src/app/(dashboard)/dashboard/usage/components/SessionsTab.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 } }, "src/app/(dashboard)/dashboard/webhooks/WebhooksPageClient.tsx": { @@ -1437,17 +1303,6 @@ "count": 1 } }, - "src/app/(dashboard)/home/ProviderQuotaWidget.tsx": { - "react-hooks/purity": { - "count": 1 - }, - "react-hooks/refs": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/app/api/assess/route.ts": { "@typescript-eslint/no-unused-vars": { "count": 1 @@ -2298,9 +2153,6 @@ "src/shared/components/RequestLoggerV2.tsx": { "@typescript-eslint/no-unused-vars": { "count": 3 - }, - "react-hooks/exhaustive-deps": { - "count": 6 } }, "src/shared/components/RequestTimeline.tsx": { diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 897445fd0c..eafd0121b9 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -2,7 +2,7 @@ import { useTranslations } from "next-intl"; -import { useState, useEffect, useMemo, useCallback, useRef } from "react"; +import { useState, useEffect, useMemo, useCallback, useRef, useSyncExternalStore } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Card, CardSkeleton, Button, Modal } from "@/shared/components"; @@ -106,6 +106,12 @@ const INLINE_LINK = "text-primary hover:underline"; const DOCS_LINK = "hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"; +// Stable no-op subscription for useSyncExternalStore reads of never-changing +// browser globals (location.origin does not change without a full navigation). +function emptySubscribe() { + return () => {}; +} + export default function HomePageClient({ machineId }: HomePageClientProps) { const router = useRouter(); const isElectron = useIsElectron(); @@ -115,7 +121,13 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { const [providerConnections, setProviderConnections] = useState([]); const [models, setModels] = useState([]); const [loading, setLoading] = useState(true); - const [baseUrl, setBaseUrl] = useState("/v1"); + // useSyncExternalStore keeps SSR/hydration consistent ("/v1" on the server, + // the real origin after hydration) without a setState-in-effect round-trip. + const baseUrl = useSyncExternalStore( + emptySubscribe, + () => `${globalThis.location.origin}/v1`, + () => "/v1" + ); const [selectedProvider, setSelectedProvider] = useState(null); const [providerMetrics, setProviderMetrics] = useState>({}); const [providerTopology, setProviderTopology] = useState({ lastProvider: "", errorProvider: "" }); @@ -135,36 +147,39 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { // Platform detection and download links for Electron const platform = typeof globalThis.window === "undefined" ? undefined : globalThis.window.electronAPI?.platform; + // Destructured to locals: `versionInfo?.current` in a dependency array trips + // the lint heuristic that treats any `.current` access as a mutable ref read. + const installedVersion = versionInfo?.current || ""; + const latestVersion = versionInfo?.latest || ""; const electronDownload = useMemo(() => { - const latest = versionInfo?.latest || ""; - const cleanLatest = latest.replace(/^v/, ""); + const cleanLatest = latestVersion.replace(/^v/, ""); if (platform === "darwin") { return { label: t("downloadDmg"), url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.dmg`, - desc: t("downloadDmgDescription", { version: versionInfo?.current || "" }), + desc: t("downloadDmgDescription", { version: installedVersion }), }; } if (platform === "win32") { return { label: t("downloadExe"), url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute.Setup.${cleanLatest}.exe`, - desc: t("downloadExeDescription", { version: versionInfo?.current || "" }), + desc: t("downloadExeDescription", { version: installedVersion }), }; } if (platform === "linux") { return { label: t("downloadAppImage"), url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.AppImage`, - desc: t("downloadAppImageDescription", { version: versionInfo?.current || "" }), + desc: t("downloadAppImageDescription", { version: installedVersion }), }; } return { label: t("downloadUpdate"), url: `https://github.com/diegosouzapw/OmniRoute/releases/tag/v${cleanLatest}`, - desc: t("downloadUpdateDescription", { version: versionInfo?.current || "" }), + desc: t("downloadUpdateDescription", { version: installedVersion }), }; - }, [platform, t, versionInfo?.latest, versionInfo?.current]); + }, [platform, t, latestVersion, installedVersion]); // Electron internal auto-updater state and listeners const [electronUpdateStatus, setElectronUpdateStatus] = useState<{ @@ -234,12 +249,6 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { }); }, []); - useEffect(() => { - if (typeof globalThis.window !== "undefined") { - setBaseUrl(`${globalThis.location.origin}/v1`); - } - }, []); - const fetchData = useCallback(async () => { try { const [provRes, modelsRes, versionRes] = await Promise.all([ @@ -267,7 +276,9 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { }, []); useEffect(() => { - fetchData(); + void (async () => { + await fetchData(); + })(); }, [fetchData]); // Fetch provider nodes for display labels (compat providers) diff --git a/src/app/(dashboard)/dashboard/activity/ActivityFeedClient.tsx b/src/app/(dashboard)/dashboard/activity/ActivityFeedClient.tsx index 7b3b70187c..aa4630e3ce 100644 --- a/src/app/(dashboard)/dashboard/activity/ActivityFeedClient.tsx +++ b/src/app/(dashboard)/dashboard/activity/ActivityFeedClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useTranslations } from "next-intl"; import type { AuditLogEntry } from "@/lib/compliance/index"; import ActivityFeed from "./components/ActivityFeed"; @@ -14,7 +14,9 @@ export default function ActivityFeedClient() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [category, setCategory] = useState("all"); - const referenceNowMs = useRef(Date.now()); + // State (not a ref) because it is rendered: refs cannot be read during + // render, and Date.now() cannot run there either — the fetch settles it. + const [referenceNowMs, setReferenceNowMs] = useState(0); const fetchEntries = useCallback(async () => { setLoading(true); @@ -30,7 +32,7 @@ export default function ActivityFeedClient() { } const data = (await res.json()) as AuditLogEntry[]; // Reset reference time on fresh load so relative timestamps are stable - referenceNowMs.current = Date.now(); + setReferenceNowMs(Date.now()); setAllEntries(Array.isArray(data) ? data : []); } catch (err: unknown) { const msg = err instanceof Error ? err.message : t("fetchFailed"); @@ -41,7 +43,9 @@ export default function ActivityFeedClient() { }, [t]); useEffect(() => { - fetchEntries(); + void (async () => { + await fetchEntries(); + })(); }, [fetchEntries]); const filtered = @@ -114,7 +118,7 @@ export default function ActivityFeedClient() { {t("loadingActivity")} ) : ( - + )} diff --git a/src/app/(dashboard)/dashboard/analytics/CacheHealthTab.tsx b/src/app/(dashboard)/dashboard/analytics/CacheHealthTab.tsx index 7c3fc5d111..3f3bb0477c 100644 --- a/src/app/(dashboard)/dashboard/analytics/CacheHealthTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/CacheHealthTab.tsx @@ -95,7 +95,9 @@ export default function CacheHealthTab() { }, []); useEffect(() => { - void load(range); + void (async () => { + await load(range); + })(); }, [load, range]); if (loading) return ; @@ -179,8 +181,8 @@ export default function CacheHealthTab() { {text(t, "cacheHealthConcentration", "Where the writes are concentrated")} - {text(t, "cacheHealthThreshold", "outlier above")} {compact(data.heavyWriteThreshold)}{" "} - {text(t, "cacheHealthTokens", "tokens")} + {text(t, "cacheHealthThreshold", "outlier above")}{" "} + {compact(data.heavyWriteThreshold)} {text(t, "cacheHealthTokens", "tokens")}

@@ -216,7 +218,9 @@ export default function CacheHealthTab() { - + diff --git a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx index 8d8c5c5573..d36ea43170 100644 --- a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx @@ -852,7 +852,9 @@ export default function ComboHealthTab() { useEffect(() => { const controller = new AbortController(); - fetchData(controller, false); + void (async () => { + await fetchData(controller, false); + })(); return () => controller.abort(); }, [fetchData]); diff --git a/src/app/(dashboard)/dashboard/analytics/ProviderUtilizationTab.tsx b/src/app/(dashboard)/dashboard/analytics/ProviderUtilizationTab.tsx index 84e99583c1..8be4d821ca 100644 --- a/src/app/(dashboard)/dashboard/analytics/ProviderUtilizationTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/ProviderUtilizationTab.tsx @@ -131,7 +131,9 @@ export default function ProviderUtilizationTab() { useEffect(() => { const controller = new AbortController(); - fetchUtilization(range, aggregateBy, controller.signal); + void (async () => { + await fetchUtilization(range, aggregateBy, controller.signal); + })(); return () => controller.abort(); }, [fetchUtilization, range, aggregateBy]); @@ -340,12 +342,8 @@ export default function ProviderUtilizationTab() {
-

- {cardTitle} -

-

- {cardSubtitle} -

+

{cardTitle}

+

{cardSubtitle}

{ const controller = new AbortController(); - fetchLogs(controller.signal); + void (async () => { + await fetchLogs(controller.signal); + })(); return () => controller.abort(); }, [fetchLogs]); useEffect(() => { if (!selectedId) return; const controller = new AbortController(); - fetchExplanation(selectedId, controller.signal); + void (async () => { + await fetchExplanation(selectedId, controller.signal); + })(); return () => controller.abort(); }, [fetchExplanation, selectedId]); diff --git a/src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx b/src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx index 2c390c68cd..f6f77da028 100644 --- a/src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx +++ b/src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx @@ -64,7 +64,9 @@ export default function A2aAuditTab() { }, [offset, skillFilter, stateFilter]); useEffect(() => { - void fetchTasks(); + void (async () => { + await fetchTasks(); + })(); }, [fetchTasks]); return ( diff --git a/src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx b/src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx index a8f86190cc..3983c39e9a 100644 --- a/src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx +++ b/src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx @@ -110,7 +110,9 @@ export default function ComplianceTab() { }, [actor, eventType, from, offset, t, to]); useEffect(() => { - void fetchEntries(); + void (async () => { + await fetchEntries(); + })(); }, [fetchEntries]); const visibleEntries = useMemo(() => { @@ -330,7 +332,9 @@ export default function ComplianceTab() {
{text(t, "cacheHealthModel", "Model")} + {text(t, "cacheHealthModel", "Model")} + {text(t, "cacheHealthCalls", "Calls")} - {t.has(`eventTypes.${entry.action}`) ? t(`eventTypes.${entry.action}`) : entry.action} + {t.has(`eventTypes.${entry.action}`) + ? t(`eventTypes.${entry.action}`) + : entry.action} diff --git a/src/app/(dashboard)/dashboard/audit/McpAuditTab.tsx b/src/app/(dashboard)/dashboard/audit/McpAuditTab.tsx index fc801b42f1..bd895cbd6d 100644 --- a/src/app/(dashboard)/dashboard/audit/McpAuditTab.tsx +++ b/src/app/(dashboard)/dashboard/audit/McpAuditTab.tsx @@ -56,7 +56,9 @@ export default function McpAuditTab() { }, []); useEffect(() => { - void fetchStats(); + void (async () => { + await fetchStats(); + })(); }, [fetchStats]); const fetchAudit = useCallback(async () => { @@ -86,7 +88,9 @@ export default function McpAuditTab() { }, [offset, successFilter, t, toolFilter]); useEffect(() => { - void fetchAudit(); + void (async () => { + await fetchAudit(); + })(); }, [fetchAudit]); return ( diff --git a/src/app/(dashboard)/dashboard/cache/components/CacheEntriesTab.tsx b/src/app/(dashboard)/dashboard/cache/components/CacheEntriesTab.tsx index a9e93c887a..0847fdc87a 100644 --- a/src/app/(dashboard)/dashboard/cache/components/CacheEntriesTab.tsx +++ b/src/app/(dashboard)/dashboard/cache/components/CacheEntriesTab.tsx @@ -63,7 +63,9 @@ export default function CacheEntriesTab() { ); useEffect(() => { - fetchEntries(); + void (async () => { + await fetchEntries(); + })(); }, [fetchEntries]); const handleDelete = async (signature: string) => { diff --git a/src/app/(dashboard)/dashboard/cache/components/ReasoningCacheTab.tsx b/src/app/(dashboard)/dashboard/cache/components/ReasoningCacheTab.tsx index 65e9df2fcb..68cadc7869 100644 --- a/src/app/(dashboard)/dashboard/cache/components/ReasoningCacheTab.tsx +++ b/src/app/(dashboard)/dashboard/cache/components/ReasoningCacheTab.tsx @@ -130,9 +130,12 @@ export default function ReasoningCacheTab() { const [loading, setLoading] = useState(true); const [clearing, setClearing] = useState(false); const [expandedId, setExpandedId] = useState(null); + // Snapshot of "now" taken when the data lands (never during render — the + // purity rule bars Date.now() there); entries only render after a fetch. + const [nowMs, setNowMs] = useState(0); const timeAgo = (dateStr: string): string => { - const diff = Date.now() - new Date(dateStr).getTime(); + const diff = nowMs - new Date(dateStr).getTime(); const minutes = Math.floor(diff / 60000); if (minutes < 1) return t("justNow"); if (minutes < 60) return t("minutesAgo", { minutes }); @@ -147,6 +150,7 @@ export default function ReasoningCacheTab() { const res = await fetch("/api/cache/reasoning"); if (res.ok) { const json: ReasoningCacheData = await res.json(); + setNowMs(Date.now()); setData(json); } } catch (error) { @@ -157,7 +161,9 @@ export default function ReasoningCacheTab() { }, []); useEffect(() => { - void fetchData(); + void (async () => { + await fetchData(); + })(); const id = setInterval(() => void fetchData(), REFRESH_INTERVAL_MS); return () => clearInterval(id); }, [fetchData]); diff --git a/src/app/(dashboard)/dashboard/cache/page.tsx b/src/app/(dashboard)/dashboard/cache/page.tsx index 11ad9c89ba..04762e782b 100644 --- a/src/app/(dashboard)/dashboard/cache/page.tsx +++ b/src/app/(dashboard)/dashboard/cache/page.tsx @@ -374,7 +374,9 @@ export default function CachePage() { }, []); useEffect(() => { - void fetchStats(); + void (async () => { + await fetchStats(); + })(); const id = setInterval(() => void fetchStats(), REFRESH_INTERVAL_MS); return () => clearInterval(id); }, [fetchStats]); diff --git a/src/app/(dashboard)/dashboard/memory/components/EditMemoryModal.tsx b/src/app/(dashboard)/dashboard/memory/components/EditMemoryModal.tsx index e6111e8b44..60c117a817 100644 --- a/src/app/(dashboard)/dashboard/memory/components/EditMemoryModal.tsx +++ b/src/app/(dashboard)/dashboard/memory/components/EditMemoryModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Modal, Button, Input, Select } from "@/shared/components"; import { useTranslations } from "next-intl"; @@ -29,7 +29,14 @@ export default function EditMemoryModal({ memory, isOpen, onClose, onSaved }: Pr const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(""); - useEffect(() => { + // Adjust-during-render (React docs pattern): when the modal (re)opens for a + // memory, seed the form fields from it before painting — no effect round-trip. + const [prevSync, setPrevSync] = useState<{ memory: Memory | null; isOpen: boolean }>({ + memory: null, + isOpen: false, + }); + if (memory !== prevSync.memory || isOpen !== prevSync.isOpen) { + setPrevSync({ memory, isOpen }); if (memory && isOpen) { setType(memory.type); setKey(memory.key); @@ -38,7 +45,7 @@ export default function EditMemoryModal({ memory, isOpen, onClose, onSaved }: Pr setMetadataError(""); setError(""); } - }, [memory, isOpen]); + } const handleMetadataChange = (value: string) => { setMetadataStr(value); @@ -151,9 +158,7 @@ export default function EditMemoryModal({ memory, isOpen, onClose, onSaved }: Pr metadataError ? "border-red-500" : "border-border" }`} /> - {metadataError && ( -

{metadataError}

- )} + {metadataError &&

{metadataError}

} diff --git a/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx b/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx index 87ce02f3ae..58deed873f 100644 --- a/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx +++ b/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx @@ -40,7 +40,8 @@ export default function QdrantConfigCard() { collection?: { exists: boolean; vectorSize?: number; vectorName?: string | null }; } | null>(null); const [searchValidated, setSearchValidated] = useState(false); - const [tutorialOpen, setTutorialOpen] = useState(false); const [checking, setChecking] = useState(false); + const [tutorialOpen, setTutorialOpen] = useState(false); + const [checking, setChecking] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searching, setSearching] = useState(false); const [searchResults, setSearchResults] = useState< @@ -109,7 +110,8 @@ export default function QdrantConfigCard() { // invalidate in-flight checks so they cannot overwrite the new state. healthSeqRef.current += 1; setHealth(null); - setSearchValidated(false); setQdrant(next); + setSearchValidated(false); + setQdrant(next); setSaving(true); setSaveStatus(""); try { @@ -153,7 +155,8 @@ export default function QdrantConfigCard() { setSaving(false); } }, - [qdrant, checkHealth] ); + [qdrant, checkHealth] + ); // Auto-check on mount once settings load: without this the status badge // renders red after a page refresh because `health` starts as null and the @@ -161,7 +164,9 @@ export default function QdrantConfigCard() { // connection button still drives the same check manually. useEffect(() => { if (!loading && qdrant.enabled && health === null) { - void checkHealth(); + void (async () => { + await checkHealth(); + })(); } }, [loading, qdrant.enabled, health, checkHealth]); @@ -245,7 +250,8 @@ export default function QdrantConfigCard() { ? "text-text-muted" : health.ok ? "text-emerald-500" - : "text-red-500" }`} + : "text-red-500" + }`} > { - void checkHealth(); + void (async () => { + await checkHealth(); + })(); const id = setInterval(() => { void checkHealth(); }, 30_000); @@ -260,8 +260,9 @@ export default function MemoriesTab() { body: JSON.stringify({ dryRun: true, olderThanDays: 30 }), }); const data = await res.json().catch(() => null); - const candidates: string[] = - Array.isArray(data?.candidates) ? data.candidates.map((c: { key?: string }) => c?.key ?? String(c)) : []; + const candidates: string[] = Array.isArray(data?.candidates) + ? data.candidates.map((c: { key?: string }) => c?.key ?? String(c)) + : []; setSummarizeCandidates(candidates); setSummarizeDialogOpen(true); } catch { @@ -289,8 +290,7 @@ export default function MemoriesTab() { } }; - const showHitRate = - (stats.cacheStats?.hits ?? 0) + (stats.cacheStats?.misses ?? 0) > 0; + const showHitRate = (stats.cacheStats?.hits ?? 0) + (stats.cacheStats?.misses ?? 0) > 0; if (isLoading) { return ( @@ -401,9 +401,7 @@ export default function MemoriesTab() { info -
- {((stats.hitRate ?? 0) * 100).toFixed(1)}% -
+
{((stats.hitRate ?? 0) * 100).toFixed(1)}%
)} @@ -448,12 +446,8 @@ export default function MemoriesTab() { psychology -

- {t("emptyState.title")} -

-

- {t("emptyState.description")} -

+

{t("emptyState.title")}

+

{t("emptyState.description")}

@@ -477,7 +471,9 @@ export default function MemoriesTab() {
{t(memory.type)} @@ -665,7 +661,10 @@ export default function MemoriesTab() {

    {summarizeCandidates.map((key, i) => ( -
  • +
  • {key}
  • ))} diff --git a/src/app/(dashboard)/dashboard/memory/hooks/useEngineStatus.ts b/src/app/(dashboard)/dashboard/memory/hooks/useEngineStatus.ts index 5480d80a0c..62d3804656 100644 --- a/src/app/(dashboard)/dashboard/memory/hooks/useEngineStatus.ts +++ b/src/app/(dashboard)/dashboard/memory/hooks/useEngineStatus.ts @@ -39,7 +39,9 @@ export function useEngineStatus(refreshIntervalMs = 5000): UseEngineStatusResult useEffect(() => { mounted.current = true; - void fetchOnce(); + void (async () => { + await fetchOnce(); + })(); if (!refreshIntervalMs || refreshIntervalMs <= 0) { return () => { mounted.current = false; diff --git a/src/app/(dashboard)/dashboard/memory/hooks/useMemorySettings.ts b/src/app/(dashboard)/dashboard/memory/hooks/useMemorySettings.ts index ae50f246fa..9f51a39ab0 100644 --- a/src/app/(dashboard)/dashboard/memory/hooks/useMemorySettings.ts +++ b/src/app/(dashboard)/dashboard/memory/hooks/useMemorySettings.ts @@ -40,7 +40,9 @@ export function useMemorySettings(): UseMemorySettingsResult { useEffect(() => { mounted.current = true; - void fetchOnce(); + void (async () => { + await fetchOnce(); + })(); return () => { mounted.current = false; }; diff --git a/src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx b/src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx index 1d87e69baf..1db92fabde 100644 --- a/src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx +++ b/src/app/(dashboard)/dashboard/radar/RadarCatalogTable.tsx @@ -95,7 +95,9 @@ export function RadarCatalogTable({ entries, refreshCatalog, onError }: RadarCat }, [onError, t]); useEffect(() => { - void loadState(); + void (async () => { + await loadState(); + })(); }, [loadState]); const stateByKey = useMemo( diff --git a/src/app/(dashboard)/dashboard/radar/intel/page.tsx b/src/app/(dashboard)/dashboard/radar/intel/page.tsx index 7c933b053d..e2f1fd1afc 100644 --- a/src/app/(dashboard)/dashboard/radar/intel/page.tsx +++ b/src/app/(dashboard)/dashboard/radar/intel/page.tsx @@ -67,9 +67,15 @@ export default function RadarIntelPage() { }, [load, t]); useEffect(() => { - load() - .catch(() => setError(t("loadFailed"))) - .finally(() => setLoading(false)); + void (async () => { + try { + await load(); + } catch { + setError(t("loadFailed")); + } finally { + setLoading(false); + } + })(); }, [load, t]); if (flagOff) notFound(); diff --git a/src/app/(dashboard)/dashboard/radar/page.tsx b/src/app/(dashboard)/dashboard/radar/page.tsx index 4ab1c2d449..ceede9431d 100644 --- a/src/app/(dashboard)/dashboard/radar/page.tsx +++ b/src/app/(dashboard)/dashboard/radar/page.tsx @@ -201,7 +201,9 @@ export default function RadarPage() { }, [fetchCatalog, fetchReferrals]); useEffect(() => { - fetchSettings(); + void (async () => { + await fetchSettings(); + })(); }, [fetchSettings]); // Sync (defined before handleActivate which depends on it) @@ -242,7 +244,9 @@ export default function RadarPage() { if (loading || syncing || optIn !== true || autoSyncFiredRef.current) return; if (!shouldAutoSyncOnOpen(meta?.fetchedAt ?? null, Date.now())) return; autoSyncFiredRef.current = true; - void handleSync(); + void (async () => { + await handleSync(); + })(); }, [loading, syncing, optIn, meta, handleSync]); // Activate opt-in diff --git a/src/app/(dashboard)/dashboard/radar/setup/page.tsx b/src/app/(dashboard)/dashboard/radar/setup/page.tsx index 927b5f4eac..fde7d36d4e 100644 --- a/src/app/(dashboard)/dashboard/radar/setup/page.tsx +++ b/src/app/(dashboard)/dashboard/radar/setup/page.tsx @@ -55,7 +55,16 @@ export default function RadarSetupPage() { const provider = searchParams.get("provider"); const [setupData, setSetupData] = useState(null); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(provider !== null); + + // Adjust-during-render when the provider query param changes (React docs + // pattern): a null provider has nothing to load, any other transition + // restarts the loading state before the fetch effect fires. + const [prevProvider, setPrevProvider] = useState(provider); + if (provider !== prevProvider) { + setPrevProvider(provider); + setLoading(provider !== null); + } const [error, setError] = useState(""); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); @@ -63,7 +72,6 @@ export default function RadarSetupPage() { // Fetch catalog to find the provider's setup data useEffect(() => { if (!provider) { - setLoading(false); return; } @@ -123,12 +131,13 @@ export default function RadarSetupPage() { }, [provider, t]); // Test connection — uses the EXISTING connection-test endpoint + const connectionId = setupData?.connectionId ?? null; const handleTestConnection = useCallback(async () => { - if (!setupData?.connectionId) return; + if (!connectionId) return; setTesting(true); setTestResult(null); try { - const res = await fetch(`/api/providers/${encodeURIComponent(setupData.connectionId)}/test`, { + const res = await fetch(`/api/providers/${encodeURIComponent(connectionId)}/test`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), @@ -147,7 +156,7 @@ export default function RadarSetupPage() { } finally { setTesting(false); } - }, [setupData?.connectionId, t]); + }, [connectionId, t]); if (!provider) { return ( diff --git a/src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx b/src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx index bc5922e8be..f6d1b6a951 100644 --- a/src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx @@ -490,18 +490,18 @@ export default function EvalsTab() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useEffect(() => { - if (targetOptions.length === 0) return; - if (targetOptions.some((option) => option.key === selectedTargetKey)) return; + // Adjust-during-render (React docs pattern): keep the selected target inside + // the current option set, and never compare a target against itself. Both + // guards self-extinguish after their setState, so the re-render settles. + if ( + targetOptions.length > 0 && + !targetOptions.some((option) => option.key === selectedTargetKey) + ) { setSelectedTargetKey(targetOptions[0]?.key || "suite-default:__default__"); - }, [selectedTargetKey, targetOptions]); - - useEffect(() => { - if (!compareTargetKey) return; - if (compareTargetKey === selectedTargetKey) { - setCompareTargetKey(""); - } - }, [compareTargetKey, selectedTargetKey]); + } + if (compareTargetKey && compareTargetKey === selectedTargetKey) { + setCompareTargetKey(""); + } const filteredSuites = !search.trim() ? suites @@ -1846,7 +1846,11 @@ export default function EvalsTab() { ); } -const HeroSection = memo(function HeroSection({ t }: { t: (key: string, values?: Record) => string }) { +const HeroSection = memo(function HeroSection({ + t, +}: { + t: (key: string, values?: Record) => string; +}) { return (
    >): void { + keys.current = {}; +} + +function ensureIdempotencyKey( + keys: React.MutableRefObject>, + selectionToken: string +): string { + const existing = keys.current[selectionToken]; + if (existing) return existing; + const created = createIdempotencyKey(); + keys.current[selectionToken] = created; + return created; +} + function createIdempotencyKey(): string { return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() @@ -112,9 +129,7 @@ function useRedeemCodexResetCredit(state: ResetCreditRequestState) { async (selectionToken: string) => { const picker = state.resetCreditPicker; if (!picker || state.redeemingResetCreditId || !selectionToken) return; - const idempotencyKey = - state.idempotencyKeysRef.current[selectionToken] ?? - (state.idempotencyKeysRef.current[selectionToken] = createIdempotencyKey()); + const idempotencyKey = ensureIdempotencyKey(state.idempotencyKeysRef, selectionToken); state.setRedeemingResetCreditId(picker.connectionId); state.setErrors((prev) => ({ ...prev, [picker.connectionId]: null })); try { @@ -145,7 +160,7 @@ function useRedeemCodexResetCredit(state: ResetCreditRequestState) { [picker.connectionId]: new Date().toISOString(), })); state.setResetCreditPicker(null); - state.idempotencyKeysRef.current = {}; + resetIdempotencyKeys(state.idempotencyKeysRef); notify.success(state.tr("resetCreditRedeemed", "Reset redeemed")); } catch (error) { const message = getRequestErrorMessage( diff --git a/src/app/(dashboard)/dashboard/usage/components/RateLimitStatus.tsx b/src/app/(dashboard)/dashboard/usage/components/RateLimitStatus.tsx index 36bcf5fe9b..ef0a5772af 100644 --- a/src/app/(dashboard)/dashboard/usage/components/RateLimitStatus.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/RateLimitStatus.tsx @@ -22,7 +22,9 @@ export default function RateLimitStatus() { }, []); useEffect(() => { - load(); + void (async () => { + await load(); + })(); const interval = setInterval(load, 10000); return () => clearInterval(interval); }, [load]); diff --git a/src/app/(dashboard)/dashboard/usage/components/SessionsTab.tsx b/src/app/(dashboard)/dashboard/usage/components/SessionsTab.tsx index 72e2f6461e..f4cdff6a20 100644 --- a/src/app/(dashboard)/dashboard/usage/components/SessionsTab.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/SessionsTab.tsx @@ -49,7 +49,9 @@ export default function SessionsTab() { }, []); useEffect(() => { - loadSessions(); + void (async () => { + await loadSessions(); + })(); const interval = setInterval(loadSessions, 5000); return () => clearInterval(interval); }, [loadSessions]); diff --git a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx index 19e925fd2f..37b11ff286 100644 --- a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx +++ b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx @@ -161,7 +161,9 @@ export default function ProviderQuotaWidget({ const [refreshingAll, setRefreshingAll] = useState(false); const [updatedAt, setUpdatedAt] = useState(null); const refreshingAllRef = useRef(false); - const lastRefreshAllAtRef = useRef(Date.now()); + // State (not a ref): the countdown renders it, and refs cannot be read during + // render nor initialized with Date.now() (purity rule). + const [lastRefreshAllAt, setLastRefreshAllAt] = useState(() => Date.now()); const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0; const [autoRefreshClock, setAutoRefreshClock] = useState(() => Date.now()); @@ -188,14 +190,16 @@ export default function ProviderQuotaWidget({ }, []); useEffect(() => { - void loadData(); + void (async () => { + await loadData(); + })(); }, [loadData]); const refreshAll = useCallback(async () => { if (refreshingAllRef.current) return; refreshingAllRef.current = true; const now = Date.now(); - lastRefreshAllAtRef.current = now; + setLastRefreshAllAt(now); setAutoRefreshClock(now); setRefreshingAll(true); try { @@ -235,10 +239,12 @@ export default function ProviderQuotaWidget({ if (document.visibilityState !== "visible") return; if (refreshingAllRef.current) return; - if (autoRefreshClock - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) { - void refreshAll(); + if (autoRefreshClock - lastRefreshAllAt >= autoRefreshIntervalMs) { + void (async () => { + await refreshAll(); + })(); } - }, [autoRefreshClock, autoRefreshIntervalMs, refreshAll]); + }, [autoRefreshClock, lastRefreshAllAt, autoRefreshIntervalMs, refreshAll]); const providerGroups = useMemo(() => { const groups = new Map(); @@ -286,10 +292,7 @@ export default function ProviderQuotaWidget({ ? tr("refreshing", "Refreshing") : autoRefreshIntervalMs > 0 ? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown( - Math.max( - 0, - autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current) - ) + Math.max(0, autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAt)) )}` : tr("forceRefresh", "Refresh now")} diff --git a/src/shared/components/RequestLoggerV2.tsx b/src/shared/components/RequestLoggerV2.tsx index f9cd90a63b..924ee43013 100644 --- a/src/shared/components/RequestLoggerV2.tsx +++ b/src/shared/components/RequestLoggerV2.tsx @@ -55,6 +55,16 @@ import { // Reduced from 300 → 50 to avoid browser freeze and network saturation. const PAGE_SIZE = 50; +// Column sort toggle mapping: clicking a column header toggles asc/desc. +const COLUMN_SORT_MAP = { + status: { desc: "status_desc", asc: "status_asc" }, + model: { desc: "model_desc", asc: "model_asc" }, + tokens: { desc: "tokens_desc", asc: "tokens_asc" }, + tps: { desc: "tps_desc", asc: "tps_asc" }, + duration: { desc: "duration_desc", asc: "duration_asc" }, + time: { desc: "newest", asc: "oldest" }, +} as const; + function getLogTotalTokens(log) { return (log?.tokens?.in || 0) + (log?.tokens?.out || 0); } @@ -147,17 +157,8 @@ const RequestLoggerV2 = forwardRef { - const mapping = columnSortMap[column as keyof typeof columnSortMap]; + const mapping = COLUMN_SORT_MAP[column as keyof typeof COLUMN_SORT_MAP]; if (!mapping) return; setSortBy((prev) => { if (prev === mapping.desc) return mapping.asc; @@ -166,7 +167,7 @@ const RequestLoggerV2 = forwardRef { - const mapping = columnSortMap[column as keyof typeof columnSortMap]; + const mapping = COLUMN_SORT_MAP[column as keyof typeof COLUMN_SORT_MAP]; if (!mapping) return ""; if (sortBy === mapping.desc) return " ↓"; if (sortBy === mapping.asc) return " ↑"; @@ -535,89 +536,7 @@ const RequestLoggerV2 = forwardRef { - // Guard: if no valid id provided, close instead of opening an empty modal - if (!logEntry?.id) { - try { - closeDetail(); - } catch {} - return; - } - - const requestToken = `${logEntry.id}:${Date.now()}:${Math.random()}`; - detailRequestRef.current = requestToken; - const isCurrentDetailRequest = () => detailRequestRef.current === requestToken; - - setSelectedLog(logEntry); - try { - const url = new URL(globalThis.location.href); - url.searchParams.set("id", logEntry.id); - router.replace(url.pathname + url.search, { scroll: false }); - } catch (e) { - // ignore navigation errors - } - setDetailLoading(true); - setDetailData(null); - try { - const res = await fetch(`/api/logs/${logEntry.id}`, { cache: "no-store" }); - if (res.ok) { - const data = await res.json(); - if (!isCurrentDetailRequest()) return; - const dataHasPipeline = - data?.pipelinePayloads && Object.keys(data.pipelinePayloads || {}).length > 0; - setDetailData((prev: { pipelinePayloads: any }) => ({ - ...prev, - ...data, - pipelinePayloads: dataHasPipeline ? data.pipelinePayloads : prev?.pipelinePayloads, - })); - // ensure the modal summary reflects the fetched call log summary - if (data && typeof data === "object") { - setSelectedLog((prev: any) => ({ - ...prev, - ...data, - active: data.active === true, - })); - } - } else { - // A deep-linked id can legitimately 404 while the request is still - // finalizing. Keep the modal open and poll /api/logs/[id] instead of - // falling back to an in-memory active-request endpoint. - if (!isCurrentDetailRequest()) return; - if (res.status === 404) { - if (logEntry.pendingLookup || logEntry.active) { - setSelectedLog((prev: { method: any; path: any }) => ({ - ...prev, - id: logEntry.id, - status: 0, - method: prev?.method, - path: prev?.path || "", - })); - setDetailData({ detailState: "pending" }); - return; - } - try { - console.warn("Log not found:", logEntry.id); - } catch {} - try { - closeDetail(); - } catch {} - return; - } - // other errors: show a minimal error indicator by setting detailData to an error object - try { - const body = await res.text().catch(() => null); - if (!isCurrentDetailRequest()) return; - setDetailData({ error: `Failed to fetch log (status ${res.status})`, body }); - } catch {} - } - } catch (error) { - console.error("Failed to fetch log detail:", error); - } finally { - if (isCurrentDetailRequest()) setDetailLoading(false); - } - }; - - const closeDetail = () => { + const closeDetail = useCallback(() => { detailRequestRef.current = ""; setSelectedLog(null); setDetailData(null); @@ -629,7 +548,92 @@ const RequestLoggerV2 = forwardRef { + // Guard: if no valid id provided, close instead of opening an empty modal + if (!logEntry?.id) { + try { + closeDetail(); + } catch {} + return; + } + + const requestToken = `${logEntry.id}:${Date.now()}:${Math.random()}`; + detailRequestRef.current = requestToken; + const isCurrentDetailRequest = () => detailRequestRef.current === requestToken; + + setSelectedLog(logEntry); + try { + const url = new URL(globalThis.location.href); + url.searchParams.set("id", logEntry.id); + router.replace(url.pathname + url.search, { scroll: false }); + } catch (e) { + // ignore navigation errors + } + setDetailLoading(true); + setDetailData(null); + try { + const res = await fetch(`/api/logs/${logEntry.id}`, { cache: "no-store" }); + if (res.ok) { + const data = await res.json(); + if (!isCurrentDetailRequest()) return; + const dataHasPipeline = + data?.pipelinePayloads && Object.keys(data.pipelinePayloads || {}).length > 0; + setDetailData((prev: { pipelinePayloads: any }) => ({ + ...prev, + ...data, + pipelinePayloads: dataHasPipeline ? data.pipelinePayloads : prev?.pipelinePayloads, + })); + // ensure the modal summary reflects the fetched call log summary + if (data && typeof data === "object") { + setSelectedLog((prev: any) => ({ + ...prev, + ...data, + active: data.active === true, + })); + } + } else { + // A deep-linked id can legitimately 404 while the request is still + // finalizing. Keep the modal open and poll /api/logs/[id] instead of + // falling back to an in-memory active-request endpoint. + if (!isCurrentDetailRequest()) return; + if (res.status === 404) { + if (logEntry.pendingLookup || logEntry.active) { + setSelectedLog((prev: { method: any; path: any }) => ({ + ...prev, + id: logEntry.id, + status: 0, + method: prev?.method, + path: prev?.path || "", + })); + setDetailData({ detailState: "pending" }); + return; + } + try { + console.warn("Log not found:", logEntry.id); + } catch {} + try { + closeDetail(); + } catch {} + return; + } + // other errors: show a minimal error indicator by setting detailData to an error object + try { + const body = await res.text().catch(() => null); + if (!isCurrentDetailRequest()) return; + setDetailData({ error: `Failed to fetch log (status ${res.status})`, body }); + } catch {} + } + } catch (error) { + console.error("Failed to fetch log detail:", error); + } finally { + if (isCurrentDetailRequest()) setDetailLoading(false); + } + }, + [closeDetail, router] + ); const sortedLogsForNav = useMemo(() => sortedLogs, [sortedLogs]); @@ -654,7 +658,7 @@ const RequestLoggerV2 = forwardRef { const isActive = selectedLog?.active === true; @@ -765,7 +769,7 @@ const RequestLoggerV2 = forwardRef { const idx = currentLogIndex; @@ -781,7 +785,7 @@ const RequestLoggerV2 = forwardRef