From 2af6923e6e51daeeeaff3686ad265537b74bb992 Mon Sep 17 00:00:00 2001 From: Mourad Maatoug Date: Sat, 16 May 2026 17:06:12 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20v3.8.0=20polish=20=E2=80=94=20connec?= =?UTF-8?q?tions=20border,=20sticky=20tabs,=20EN=20translations,=20save=20?= =?UTF-8?q?toasts,=20auto-combo=20catalog=20(#2305)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.0 --- .../dashboard/combos/AutoComboCatalog.tsx | 85 ++++++++++++ src/app/(dashboard)/dashboard/combos/page.tsx | 4 + .../dashboard/providers/[id]/page.tsx | 4 +- .../components/ModelCooldownsCard.tsx | 22 ++-- .../settings/components/ResilienceTab.tsx | 96 +++++++------- .../settings/components/RoutingTab.tsx | 6 + .../(dashboard)/dashboard/settings/page.tsx | 2 +- src/i18n/messages/en.json | 23 +++- tests/unit/AutoComboCatalog.test.tsx | 121 ++++++++++++++++++ 9 files changed, 297 insertions(+), 66 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/combos/AutoComboCatalog.tsx create mode 100644 tests/unit/AutoComboCatalog.test.tsx diff --git a/src/app/(dashboard)/dashboard/combos/AutoComboCatalog.tsx b/src/app/(dashboard)/dashboard/combos/AutoComboCatalog.tsx new file mode 100644 index 0000000000..a072fa8f1b --- /dev/null +++ b/src/app/(dashboard)/dashboard/combos/AutoComboCatalog.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { Card } from "@/shared/components"; +import { AUTO_COMBO_TEMPLATES } from "@/domain/assessment/types"; + +// Informational catalog of zero-config auto-routing combos. +// Auto combos are resolved at request time by the chat handler based on the +// currently connected providers / models — they have no row in the combos +// table, so they were previously invisible in the UI. This panel surfaces +// the static catalog (name, intent, categories, tiers, strategy) so users +// can discover the auto/ prefix without reading source. +export default function AutoComboCatalog() { + const t = useTranslations("combos"); + const [open, setOpen] = useState(false); + + return ( + + + + {open && ( +
+ {AUTO_COMBO_TEMPLATES.map((tpl) => ( +
+
+ {tpl.name} + + {tpl.strategy} + +
+

{tpl.displayName}

+
+ {tpl.categories.map((cat) => ( + + {cat} + + ))} + {tpl.tiers.map((tier) => ( + + {tier} + + ))} +
+ {tpl.systemMessage && ( +

+ {tpl.systemMessage} +

+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 245fd2f9ab..d3f0c2a5b5 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -34,6 +34,7 @@ import { resolveComboBuilderProviderId, } from "@/lib/combos/builderDraft"; import { normalizeComboConfigMode } from "@/shared/constants/comboConfigMode"; +import AutoComboCatalog from "./AutoComboCatalog"; import BuilderIntelligentStep from "./BuilderIntelligentStep"; import IntelligentComboPanel from "./IntelligentComboPanel"; import { @@ -950,6 +951,7 @@ export default function CombosPage() {

{t("title")}

{t("description")}

+
@@ -988,6 +990,8 @@ export default function CombosPage() {
+ + {showUsageGuide && ( setShowUsageGuide(false)} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 284d319e44..3cd0031338 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -3380,7 +3380,7 @@ export default function ProviderDetailPage() { )} -
+
{sorted.map((conn, index) => ( ) : null} -
+
{groupKeys.map((tag, gi) => { const groupConns = groupMap.get(tag)!; return ( diff --git a/src/app/(dashboard)/dashboard/settings/components/ModelCooldownsCard.tsx b/src/app/(dashboard)/dashboard/settings/components/ModelCooldownsCard.tsx index 5ca9122b93..5baed73d09 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ModelCooldownsCard.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ModelCooldownsCard.tsx @@ -58,7 +58,7 @@ export default function ModelCooldownsCard() { }); const json = await res.json(); if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`); - notify.success(`Modelo reativado: ${provider}/${model}`); + notify.success(`Model reactivated: ${provider}/${model}`); await load(); } catch (error) { notify.error(error instanceof Error ? error.message : "Failed to clear cooldown"); @@ -79,7 +79,7 @@ export default function ModelCooldownsCard() { }); const json = await res.json(); if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`); - notify.success("Todos os modelos em cooldown foram reativados."); + notify.success("All models in cooldown have been reactivated."); await load(); } catch (error) { notify.error(error instanceof Error ? error.message : "Failed to clear cooldowns"); @@ -95,15 +95,15 @@ export default function ModelCooldownsCard() {
-

Modelos em cooldown

+

Models in cooldown

- Lista de modelos temporariamente isolados por falha. Quando o cooldown expira, eles - voltam automaticamente. + Models temporarily isolated after a failure. When the cooldown expires they come back + automatically.

{loading ? ( -

Carregando...

+

Loading...

) : !hasItems ? ( -

Nenhum modelo em cooldown no momento.

+

No models in cooldown right now.

) : ( sorted.map((item) => { const rowKey = `${item.provider}::${item.model}`; @@ -134,7 +134,7 @@ export default function ModelCooldownsCard() { {item.provider}/{item.model}

- motivo: {item.reason} • restante: {formatRemaining(item.remainingMs)} + reason: {item.reason} • remaining: {formatRemaining(item.remainingMs)}

); diff --git a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx index d408fb7687..bcc92aaeed 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx @@ -65,13 +65,13 @@ function SectionDescription({ return (
- Escopo: {scope} + Scope: {scope}
- Gatilho: {trigger} + Trigger: {trigger}
- Efeito: {effect} + Effect: {effect}
); @@ -216,12 +216,12 @@ function RequestQueueCard({
speed -

Fila de Requisições e Ritmo

+

Request Queue & Rate

- Esta camada controla apenas enfileiramento e ritmo. Ela não grava cooldown nem abre circuit - breaker. + This layer only controls queueing and pacing. It does not write cooldown state nor open the + circuit breaker.

{editing ? ( <> setDraft((prev) => ({ ...prev, autoEnableApiKeyProviders })) } /> setDraft((prev) => ({ ...prev, requestsPerMinute }))} /> @@ -270,7 +270,7 @@ function RequestQueueCard({ } /> @@ -278,7 +278,7 @@ function RequestQueueCard({ } />
-
Autoativar para provedores com API key
+
Auto-enable for API-key providers
- {value.autoEnableApiKeyProviders ? "Ativado" : "Desativado"} + {value.autoEnableApiKeyProviders ? "Enabled" : "Disabled"}
-
Requisições por minuto
+
Requests per minute
{value.requestsPerMinute}
-
Tempo mínimo entre requisições
+
Minimum time between requests
{formatMs(value.minTimeBetweenRequestsMs)}
-
Requisições concorrentes
+
Concurrent requests
{value.concurrentRequests}
-
Tempo máximo de espera em fila
+
Maximum queue wait time
{formatMs(value.maxWaitMs)}
@@ -347,7 +347,7 @@ function ConnectionCooldownCard({ {editing ? ( <>
- Cooldown base + Base cooldown {formatMs(current.baseCooldownMs)}
@@ -533,7 +533,7 @@ function ProviderBreakerCard({ {editing ? ( <> @@ -541,7 +541,7 @@ function ProviderBreakerCard({ } />
- Limite de falhas + Failure threshold {current.failureThreshold}
- Tempo para reset + Reset timeout {formatMs(current.resetTimeoutMs)}
@@ -574,12 +574,12 @@ function ProviderBreakerCard({ electrical_services -

Circuit Breaker por Provedor

+

Circuit Breaker per Provider

- O estado em tempo real do breaker aparece apenas na página Saúde. Rate limits 429 no escopo - de conexão ficam no Cooldown de Conexão e não disparam o breaker de provedor. + The live breaker state is shown only on the Health page. 429 rate limits at the connection + scope stay in Connection Cooldown and do not trip the provider breaker.

- {renderProfile("oauth", "Provedores OAuth", "lock")} - {renderProfile("apikey", "Provedores API Key", "key")} + {renderProfile("oauth", "OAuth Providers", "lock")} + {renderProfile("apikey", "API Key Providers", "key")}
); @@ -632,12 +632,12 @@ function WaitForCooldownCard({
hourglass_top -

Aguardar Cooldown

+

Wait for Cooldown

- Isso afeta apenas a requisição atual. Não grava estado de conexão nem de provedor. + This only affects the current request. No connection or provider state is written.

{editing ? ( <> setDraft((prev) => ({ ...prev, enabled }))} /> setDraft((prev) => ({ ...prev, maxRetries }))} />
-
Ativar espera no servidor
+
Enable server-side wait
- {value.enabled ? "Ativado" : "Desativado"} + {value.enabled ? "Enabled" : "Disabled"}
-
Máximo de tentativas
+
Maximum retries
{value.maxRetries}
-
Tempo máximo de espera por tentativa
+
Maximum wait per retry
{value.maxRetryWaitSec}s
diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index c80b875ed6..e55d2a7073 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import { Button, Card, Collapsible, Input, Select, Toggle } from "@/shared/components"; import { useTranslations } from "next-intl"; +import { useNotificationStore } from "@/store/notificationStore"; import FallbackChainsEditor from "./FallbackChainsEditor"; import { CLI_COMPAT_PROVIDER_DISPLAY, @@ -629,6 +630,7 @@ export default function RoutingTab() { const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false); const [lkgpCacheStatus, setLkgpCacheStatus] = useState({ type: "", message: "" }); const t = useTranslations("settings"); + const notify = useNotificationStore(); useEffect(() => { fetch("/api/settings") @@ -670,11 +672,15 @@ export default function RoutingTab() { } catch { // body wasn't JSON — keep the HTTP status fallback } + notify.error(t("saveFailed"), serverMsg); if (onError) onError(serverMsg); else console.error("Failed to update settings:", serverMsg); + } else { + notify.success(t("savedSuccessfully")); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); + notify.error(t("saveFailed"), msg); if (onError) onError(msg); else console.error("Failed to update settings:", msg); } diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index ccc4369dcf..ecc54f1133 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -45,7 +45,7 @@ export default function SettingsPage() {
{/* Tab navigation */} -
+
({ + useTranslations: () => (key: string, values?: Record) => { + if (values && typeof values.count !== "undefined") { + return `${values.count} ${key}`; + } + return key; + }, +})); + +const cleanupCallbacks: Array<() => void> = []; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => { + container.remove(); + }); + return container; +} + +describe("AutoComboCatalog", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + while (cleanupCallbacks.length > 0) { + cleanupCallbacks.pop()?.(); + } + document.body.innerHTML = ""; + }); + + it("renders the header with translated title and template-count badge", async () => { + const { default: AutoComboCatalog } = + await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog"); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + expect(container.textContent).toContain("autoCatalogTitle"); + expect(container.textContent).toContain( + `${AUTO_COMBO_TEMPLATES.length} autoCatalogTemplateCount` + ); + }); + + it("stays collapsed by default — no template rows in the DOM", async () => { + const { default: AutoComboCatalog } = + await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog"); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const first = AUTO_COMBO_TEMPLATES[0]; + expect(container.textContent ?? "").not.toContain(first.name); + }); + + it("expands when toggled and lists every template name", async () => { + const { default: AutoComboCatalog } = + await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog"); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const toggle = container.querySelector("button"); + expect(toggle).toBeTruthy(); + await act(async () => { + toggle?.click(); + }); + for (const tpl of AUTO_COMBO_TEMPLATES) { + expect(container.textContent ?? "").toContain(tpl.name); + } + }); + + it("flips the toggle aria-label between expand and collapse", async () => { + const { default: AutoComboCatalog } = + await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog"); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const toggle = container.querySelector("button"); + expect(toggle?.getAttribute("aria-label")).toBe("autoCatalogExpand"); + expect(toggle?.getAttribute("aria-expanded")).toBe("false"); + await act(async () => { + toggle?.click(); + }); + expect(toggle?.getAttribute("aria-label")).toBe("autoCatalogCollapse"); + expect(toggle?.getAttribute("aria-expanded")).toBe("true"); + }); + + it("renders the strategy badge for each template when expanded", async () => { + const { default: AutoComboCatalog } = + await import("@/app/(dashboard)/dashboard/combos/AutoComboCatalog"); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + await act(async () => { + (container.querySelector("button") as HTMLButtonElement | null)?.click(); + }); + const strategies = new Set(AUTO_COMBO_TEMPLATES.map((t) => t.strategy)); + for (const s of strategies) { + expect(container.textContent ?? "").toContain(s); + } + }); +});