diff --git a/docs/ports/upstream-2526.md b/docs/ports/upstream-2526.md new file mode 100644 index 000000000..71d782c1c --- /dev/null +++ b/docs/ports/upstream-2526.md @@ -0,0 +1,29 @@ +# Port: 9router #2526 — Hide disabled provider connections in combo picker + +## Source +- Upstream: `decolua/9router` PR #2526 +- Fetched via: `gh pr diff 2526 -R decolua/9router` +- Preflight: `filterActiveConnections` absent on `origin/dev` (git grep empty); dev's `src/shared/utils/connectionStatus.js` held only `getStatusVariant`; combos page used raw `providersData.connections || []`. Not a duplicate. + +## Behavior / adaptation +Disabled provider connections (`isActive === false`) no longer appear as combo targets on the combos dashboard page. Enabled connections, legacy rows without the `isActive` flag, and no-auth connections remain visible. + +- New helper `filterActiveConnections(connections)` in `src/shared/utils/connectionStatus.js` (JSDoc-documented at the change site): non-array input → `[]`; keeps every connection except those explicitly `isActive === false`. +- `src/app/(dashboard)/dashboard/combos/page.js` wraps `providersData.connections` with the filter before `setActiveProviders`, matching the upstream diff verbatim (JS-to-JS, no adaptation needed). + +## Files +- `src/shared/utils/connectionStatus.js` — added `filterActiveConnections` (+ JSDoc) +- `src/app/(dashboard)/dashboard/combos/page.js` — import + apply filter in `fetchData` +- `tests/unit/connection-status.test.js` — focused unit test (new) + +## Test +`tests/unit/connection-status.test.js` covers the acceptance controls directly: +- `isActive: true` connection kept +- `isActive: false` connection hidden +- legacy row without `isActive` kept +- no-auth connections (with/without flag) kept +- mixed list filters to non-disabled only +- invalid input (`undefined`/`null`) → `[]` + +## Verification +No gates run per assignment (parent orchestrator verifies once: `.omc/gate.sh`, lint, build). Doc form: this file plus JSDoc at the change site. diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index b2cf0b450..23704d34e 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -7,6 +7,7 @@ import { CSS } from "@dnd-kit/utilities"; import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers"; import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { filterActiveConnections } from "@/shared/utils/connectionStatus"; import { useModelCaps } from "@/shared/hooks/useModelCaps"; import { aggregateComboCapabilities } from "open-sse/providers/capabilities.js"; import { translate } from "@/i18n/runtime"; @@ -43,7 +44,7 @@ export default function CombosPage() { // Only LLM combos here - webSearch/webFetch combos belong to media-providers/web if (combosRes.ok) setCombos((combosData.combos || []).filter(c => !c.kind || c.kind === "llm")); if (providersRes.ok) { - setActiveProviders(providersData.connections || []); + setActiveProviders(filterActiveConnections(providersData.connections)); } setComboStrategies(settingsData.comboStrategies || {}); } catch (error) { diff --git a/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js index cccf4f476..5ca7ad08e 100644 --- a/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js @@ -6,6 +6,7 @@ import Link from "next/link"; import { Card, Button, Input, Toggle, ModelSelectModal } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; import { AI_PROVIDERS, MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers"; +import { filterActiveConnections } from "@/shared/utils/connectionStatus"; // Parse "providerId/model" or just "providerId" → { providerId, model } function parseModelEntry(entry) { @@ -72,7 +73,7 @@ export default function ComboDetailPage() { fetch("/api/models/alias", { cache: "no-store" }), ]); if (aliasesRes.ok) setModelAliases((await aliasesRes.json()).aliases || {}); - if (connsRes.ok) setConnections((await connsRes.json()).connections || []); + if (connsRes.ok) setConnections(filterActiveConnections((await connsRes.json()).connections)); if (!comboRes.ok) { setCombo(null); setLoading(false); return; } const c = await comboRes.json(); setCombo(c); diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index 4bcaa715b..7d1cd6996 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -260,11 +260,13 @@ export default function ModelSelectModal({ ? NO_AUTH_PROVIDER_IDS.filter((id) => (AI_PROVIDERS[id]?.serviceKinds || ["llm"]).includes(kindFilter)) : NO_AUTH_PROVIDER_IDS; - // Show connected providers, no-auth providers, and custom provider nodes + // Show connected providers and no-auth providers. Custom compatible-provider + // nodes are covered by activeConnectionIds (connection.provider === node id), + // so they appear only with a non-disabled connection — the parent already + // filtered disabled connections out of activeProviders (9router #2526). const providerIdsToShow = new Set([ - ...activeConnectionIds, // Connected providers + ...activeConnectionIds, // Connected providers (incl. active custom nodes) ...noAuthIds, // No-auth providers (kind-filtered) - ...providerNodes.map(n => n.id), // Custom provider nodes (openai-compatible, etc.) ]); // Sort by PROVIDER_ORDER diff --git a/src/shared/utils/connectionStatus.js b/src/shared/utils/connectionStatus.js index 5d44fdff9..6e8fd9479 100644 --- a/src/shared/utils/connectionStatus.js +++ b/src/shared/utils/connectionStatus.js @@ -4,3 +4,13 @@ export function getStatusVariant(isActive, effectiveStatus) { if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error"; return "default"; } + +/** + * Keep only connections eligible for pickers (combo targets, etc.). + * A connection is hidden only when explicitly disabled (`isActive === false`); + * legacy rows without the flag and no-auth connections stay visible. + */ +export function filterActiveConnections(connections) { + if (!Array.isArray(connections)) return []; + return connections.filter((connection) => connection?.isActive !== false); +} diff --git a/tests/unit/connection-status.test.js b/tests/unit/connection-status.test.js new file mode 100644 index 000000000..92ac11a8e --- /dev/null +++ b/tests/unit/connection-status.test.js @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { filterActiveConnections } from "../../src/shared/utils/connectionStatus.js"; + +describe("filterActiveConnections", () => { + it("keeps explicitly enabled connections", () => { + const active = { id: "active", isActive: true }; + expect(filterActiveConnections([active])).toEqual([active]); + }); + + it("hides explicitly disabled connections from the combo picker", () => { + const disabled = { id: "disabled", isActive: false }; + expect(filterActiveConnections([disabled])).toEqual([]); + }); + + it("keeps legacy rows without the isActive flag", () => { + const legacy = { id: "legacy" }; + expect(filterActiveConnections([legacy])).toEqual([legacy]); + }); + + it("keeps no-auth connections that are not disabled", () => { + const noAuth = { id: "noauth", authType: "none", isActive: true }; + const noAuthLegacy = { id: "noauth-legacy", authType: "none" }; + expect(filterActiveConnections([noAuth, noAuthLegacy])).toEqual([noAuth, noAuthLegacy]); + }); + + it("filters mixed lists to only non-disabled connections", () => { + const active = { id: "active", isActive: true }; + const legacy = { id: "legacy" }; + const disabled = { id: "disabled", isActive: false }; + expect(filterActiveConnections([active, disabled, legacy])).toEqual([active, legacy]); + }); + + it("returns an empty list for invalid input", () => { + expect(filterActiveConnections()).toEqual([]); + expect(filterActiveConnections(null)).toEqual([]); + }); +});