Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 53 additions & 50 deletions src/app/(dashboard)/dashboard/providers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
buildMergedOAuthProviderEntries,
buildStaticProviderEntries,
filterConfiguredProviderEntries,
shouldApplyConfiguredOnlyFilter,
shouldShowFirstProviderHint,
} from "./providerPageUtils";
import type { ProviderEntry } from "./providerPageUtils";
import { readConfiguredOnlyPreference, writeConfiguredOnlyPreference } from "./providerPageStorage";
Expand Down Expand Up @@ -422,14 +424,19 @@ export default function ProvidersPage() {
textIcon: "CC",
}));

const effectiveShowConfiguredOnly = shouldApplyConfiguredOnlyFilter(
showConfiguredOnly,
connections.length
);

const oauthProviderEntriesAll = buildMergedOAuthProviderEntries(
OAUTH_PROVIDERS,
FREE_PROVIDERS,
getProviderStats
);
const oauthProviderEntries = filterConfiguredProviderEntries(
oauthProviderEntriesAll,
showConfiguredOnly,
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly
);
Expand All @@ -444,85 +451,85 @@ export default function ProvidersPage() {
!VIDEO_PROVIDER_IDS.has(entry.providerId) &&
!EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId)
),
showConfiguredOnly,
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly
);
const aggregatorProviderEntries = filterConfiguredProviderEntries(
apiKeyProviderEntriesAll.filter((entry) => AGGREGATOR_PROVIDER_IDS.has(entry.providerId)),
showConfiguredOnly,
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly
);
const imageProviderEntries = filterConfiguredProviderEntries(
apiKeyProviderEntriesAll.filter((entry) => IMAGE_ONLY_PROVIDER_IDS.has(entry.providerId)),
showConfiguredOnly,
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly
);
const enterpriseProviderEntries = filterConfiguredProviderEntries(
apiKeyProviderEntriesAll.filter((entry) => ENTERPRISE_CLOUD_PROVIDER_IDS.has(entry.providerId)),
showConfiguredOnly,
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly
);
const videoProviderEntries = filterConfiguredProviderEntries(
apiKeyProviderEntriesAll.filter((entry) => VIDEO_PROVIDER_IDS.has(entry.providerId)),
showConfiguredOnly,
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly
);
const embeddingRerankProviderEntries = filterConfiguredProviderEntries(
apiKeyProviderEntriesAll.filter((entry) => EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId)),
showConfiguredOnly,
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly
);

const webCookieProviderEntriesAll = buildStaticProviderEntries("web-cookie", getProviderStats);
const webCookieProviderEntries = filterConfiguredProviderEntries(
webCookieProviderEntriesAll,
showConfiguredOnly,
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly
);

const localProviderEntriesAll = buildStaticProviderEntries("local", getProviderStats);
const localProviderEntries = filterConfiguredProviderEntries(
localProviderEntriesAll,
showConfiguredOnly,
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly
);

const searchProviderEntriesAll = buildStaticProviderEntries("search", getProviderStats);
const searchProviderEntries = filterConfiguredProviderEntries(
searchProviderEntriesAll,
showConfiguredOnly,
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly
);

const audioProviderEntriesAll = buildStaticProviderEntries("audio", getProviderStats);
const audioProviderEntries = filterConfiguredProviderEntries(
audioProviderEntriesAll,
showConfiguredOnly,
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly
);

const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats);
const cloudAgentProviderEntries = filterConfiguredProviderEntries(
cloudAgentProviderEntriesAll,
showConfiguredOnly,
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly
);

const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats);
const upstreamProxyEntries = filterConfiguredProviderEntries(
upstreamProxyEntriesAll,
showConfiguredOnly,
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly
);
Expand Down Expand Up @@ -552,7 +559,7 @@ export default function ProvidersPage() {
];
const compatibleProviderEntries = filterConfiguredProviderEntries(
compatibleProviderEntriesAll,
showConfiguredOnly,
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly
);
Expand Down Expand Up @@ -591,7 +598,7 @@ export default function ProvidersPage() {
);
const freeSectionEntries = filterConfiguredProviderEntries(
freeSectionEntriesAll,
showConfiguredOnly,
effectiveShowConfiguredOnly,
searchQuery
);

Expand All @@ -617,42 +624,36 @@ export default function ProvidersPage() {
);
}

const totalConfigured =
oauthProviderEntriesAll.filter((e) => Number(e.stats?.total || 0) > 0).length +
apiKeyProviderEntriesAll.filter((e) => Number(e.stats?.total || 0) > 0).length +
webCookieProviderEntriesAll.filter((e) => Number(e.stats?.total || 0) > 0).length +
localProviderEntriesAll.filter((e) => Number(e.stats?.total || 0) > 0).length;

if (totalConfigured === 0 && !searchQuery) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="flex items-center justify-center size-16 rounded-full bg-primary/10 mb-4">
<span className="material-symbols-outlined text-[32px] text-primary">dns</span>
</div>
<h2 className="text-xl font-semibold text-text-main">
{t("addFirstProvider") || "Add your first provider"}
</h2>
<p className="text-sm text-text-muted mt-2 max-w-md">
{t("addFirstProviderDesc") ||
"Connect an AI provider to start routing requests through OmniRoute. You can use free providers, API keys, or OAuth accounts."}
</p>
<div className="flex items-center gap-3 mt-4">
<a
href="https://docs.omniroute.io/providers"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
>
<span className="material-symbols-outlined text-[16px]">help</span>
{t("learnMore") || "Learn more"}
</a>
</div>
</div>
);
}
const showFirstProviderHint = shouldShowFirstProviderHint(connections.length, searchQuery);

return (
<div className="flex flex-col gap-6">
{showFirstProviderHint && (
<Card padding="lg">
<div className="flex flex-col items-center justify-center text-center">
<div className="flex items-center justify-center size-16 rounded-full bg-primary/10 mb-4">
<span className="material-symbols-outlined text-[32px] text-primary">dns</span>
</div>
<h2 className="text-xl font-semibold text-text-main">
{t("addFirstProvider") || "Add your first provider"}
</h2>
<p className="text-sm text-text-muted mt-2 max-w-md">
{t("addFirstProviderDesc") ||
"Connect an AI provider to start routing requests through OmniRoute. You can use free providers, API keys, or OAuth accounts."}
</p>
<a
href="https://docs.omniroute.io/providers"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors mt-4"
>
<span className="material-symbols-outlined text-[16px]">help</span>
{t("learnMore") || "Learn more"}
</a>
</div>
</Card>
)}

{/* Provider Summary Card */}
<Card padding="sm">
<div className="flex flex-col gap-3">
Expand All @@ -679,9 +680,10 @@ export default function ProvidersPage() {
</div>
<Toggle
size="sm"
checked={showConfiguredOnly}
checked={effectiveShowConfiguredOnly}
onChange={setShowConfiguredOnly}
label={t("showConfiguredOnly")}
disabled={connections.length === 0}
Comment on lines +683 to +686

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Toggle component's checked state is tied to effectiveShowConfiguredOnly, which is forced to false when connections.length === 0. While this correctly reflects that the filter is inactive, it creates a slightly jarring UX where the toggle might suddenly flip to 'checked' and the list of providers might shrink as soon as the user adds their first provider (if their stored preference was true).

Consider if it would be better to keep checked={showConfiguredOnly} so the toggle reflects the user's intent, even if the filter is not currently 'effective' due to the empty state logic. However, since the toggle is disabled when empty, the current implementation is also acceptable as it accurately represents the active filtering state.

className="rounded-lg border border-border bg-bg-subtle px-3 py-1.5"
/>
<button
Expand Down Expand Up @@ -954,9 +956,10 @@ export default function ProvidersPage() {
/>
<Toggle
size="sm"
checked={showConfiguredOnly}
checked={effectiveShowConfiguredOnly}
onChange={setShowConfiguredOnly}
label={t("showConfiguredOnly")}
disabled={connections.length === 0}
className="rounded-lg border border-border bg-bg-subtle px-3 py-1.5"
/>
<button
Expand Down
14 changes: 14 additions & 0 deletions src/app/(dashboard)/dashboard/providers/providerPageUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ export interface ProviderEntry<TProvider = Record<string, unknown>> {
toggleAuthType: "oauth" | "free" | "apikey";
}

export function shouldApplyConfiguredOnlyFilter(
showConfiguredOnly: boolean,
connectionCount: number
): boolean {
return showConfiguredOnly && connectionCount > 0;
}

export function shouldShowFirstProviderHint(
connectionCount: number,
searchQuery?: string
): boolean {
return connectionCount === 0 && !searchQuery?.trim();
}

type ProviderRecord<TProvider = Record<string, unknown>> = Record<string, TProvider>;

type GetProviderStats = (
Expand Down
13 changes: 13 additions & 0 deletions tests/unit/providers-page-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,19 @@ test("configured-only preference parser only enables explicit true values", () =
assert.equal(providerPageStorage.parseConfiguredOnlyPreference(undefined), false);
});

test("configured-only filter is ignored before the first provider is connected", () => {
assert.equal(providerPageUtils.shouldApplyConfiguredOnlyFilter(true, 0), false);
assert.equal(providerPageUtils.shouldApplyConfiguredOnlyFilter(false, 0), false);
assert.equal(providerPageUtils.shouldApplyConfiguredOnlyFilter(true, 1), true);
});

test("first-provider hint is shown only when no providers are connected and search is empty", () => {
assert.equal(providerPageUtils.shouldShowFirstProviderHint(0, ""), true);
assert.equal(providerPageUtils.shouldShowFirstProviderHint(0, " "), true);
assert.equal(providerPageUtils.shouldShowFirstProviderHint(0, "codex"), false);
assert.equal(providerPageUtils.shouldShowFirstProviderHint(1, ""), false);
});

test("configured-only preference storage round-trips correctly", () => {
const storage = new Map();
const mockStorage = {
Expand Down