Skip to content

Commit aad1dcb

Browse files
committed
One-click provider switcher: surfaces inline on rate-limit + persistent in StatusBar
User feedback: when a provider 429s on free-tier quota, the right action is "switch to another provider you've configured" — but the user had to manually go to /settings and pick one. Worse, if no other keys were configured, the error gave no guidance on which free-tier alternative to add. New component components/ProviderSwitcher.tsx with two variants: 1. INLINE (full panel): - Lists providers with saved API keys ≠ active as one-click "Switch to X" buttons. Click → setActiveProviderId fires → next Generate uses that provider. No page reload, no settings detour. - Free-tier configured providers highlighted with green/pos coloring + a "FREE · 30 RPM, 14,400/day" mini-summary so the user picks the path of least friction. - If no other providers configured: shows up to 4 "+ Add free key for X" CTAs pointing at the relevant settings section. Each shows the free-tier summary so the user knows what they're signing up for. - Fallback: bare "Open settings" button when nothing matches. 2. COMPACT (inline tags): up to 3 quick-switch chips next to the active provider name in the StatusBar. Always visible at the bottom of every page. Wired into 4 rate-limit error surfaces: - GeneratorShell (all 29 optimizer/generator tools) - app/suggestions/page.tsx - app/research/competitors/page.tsx - app/launch/wizard/page.tsx (top-level wizard error) All four detect rate-limit signatures via regex (rate limit / quota / 429 / retry in / too many requests) and switch from red-error styling to live-yellow warning styling + render ProviderSwitcher below. Non-rate-limit errors still show the original red panel. Also persistent: StatusBar compact ProviderSwitcher renders next to the "Provider · X" cell at the bottom of every page (md+ screens), so the user can always swap providers in one click without hitting an error first.
1 parent aebdf39 commit aad1dcb

6 files changed

Lines changed: 196 additions & 15 deletions

File tree

app/launch/wizard/page.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from "@/lib/prompts/launch-wizard";
2323
import { rememberLastGenerated } from "@/lib/next-steps";
2424
import { getCurrency } from "@/lib/currency";
25+
import { ProviderSwitcher } from "@/components/ProviderSwitcher";
2526

2627
type PhaseStatus = "pending" | "running" | "done" | "error";
2728
interface Phase {
@@ -663,7 +664,12 @@ function Inner() {
663664
</div>
664665

665666
{topError ? (
666-
<div className="border border-neg/40 bg-neg/5 text-neg text-[11px] px-3 py-2 font-mono uppercase tracking-ui-wide">{topError}</div>
667+
<div className={`border ${/rate limit|quota|too many requests|429|retry in/i.test(topError) ? "border-live/40 bg-live/5" : "border-neg/40 bg-neg/5"} px-3 py-3 space-y-3`}>
668+
<div className={`text-[11px] font-mono uppercase tracking-ui-wide ${/rate limit|quota|too many requests|429|retry in/i.test(topError) ? "text-live" : "text-neg"}`}>{topError}</div>
669+
{/rate limit|quota|too many requests|429|retry in/i.test(topError) ? (
670+
<ProviderSwitcher reason="rate-limit" />
671+
) : null}
672+
</div>
667673
) : null}
668674

669675
<div className="flex gap-2">

app/research/competitors/page.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ApiKeyGate } from "@/components/ApiKeyGate";
66
import { PageHeader } from "@/components/PageHeader";
77
import { Section, Pill } from "@/components/OutputBlocks";
88
import { CopyButton } from "@/components/CopyButton";
9+
import { ProviderSwitcher } from "@/components/ProviderSwitcher";
910
import { useThrottledStream } from "@/lib/stream-hook";
1011
import { getApiKey, getActiveBrainId, addUsage } from "@/lib/settings";
1112
import { llmStream, estimateCostUsd, tryParseJson } from "@/lib/llm";
@@ -252,8 +253,13 @@ function Inner() {
252253
/>
253254
</div>
254255
{error ? (
255-
<div className="border border-neg/40 bg-neg/5 text-neg text-[11px] px-3 py-2 font-mono uppercase tracking-ui-wide">
256-
{error}
256+
<div className={`border ${/rate limit|quota|too many requests|429|retry in/i.test(error) ? "border-live/40 bg-live/5" : "border-neg/40 bg-neg/5"} px-3 py-3 space-y-3`}>
257+
<div className={`text-[11px] font-mono uppercase tracking-ui-wide ${/rate limit|quota|too many requests|429|retry in/i.test(error) ? "text-live" : "text-neg"}`}>
258+
{error}
259+
</div>
260+
{/rate limit|quota|too many requests|429|retry in/i.test(error) ? (
261+
<ProviderSwitcher reason="rate-limit" />
262+
) : null}
257263
</div>
258264
) : null}
259265
<div className="flex gap-2">

app/suggestions/page.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ApiKeyGate } from "@/components/ApiKeyGate";
77
import { PageHeader } from "@/components/PageHeader";
88
import { Section, Pill, Kv } from "@/components/OutputBlocks";
99
import { CopyButton } from "@/components/CopyButton";
10+
import { ProviderSwitcher } from "@/components/ProviderSwitcher";
1011
import { useThrottledStream } from "@/lib/stream-hook";
1112
import { getActiveBrainId, addUsage } from "@/lib/settings";
1213
import { llmStream, estimateCostUsd, tryParseJson } from "@/lib/llm";
@@ -143,7 +144,12 @@ function Inner() {
143144
</div>
144145

145146
{error ? (
146-
<div className="border border-neg/40 bg-neg/5 text-neg text-sm px-3 py-2 mb-4">{error}</div>
147+
<div className={`border ${/rate limit|quota|too many requests|429|retry in/i.test(error) ? "border-live/40 bg-live/5" : "border-neg/40 bg-neg/5"} px-3 py-3 mb-4 space-y-3`}>
148+
<div className={`${/rate limit|quota|too many requests|429|retry in/i.test(error) ? "text-live" : "text-neg"} text-sm`}>{error}</div>
149+
{/rate limit|quota|too many requests|429|retry in/i.test(error) ? (
150+
<ProviderSwitcher reason="rate-limit" />
151+
) : null}
152+
</div>
147153
) : null}
148154

149155
{!running && !stream.text && !parsed ? (

components/GeneratorShell.tsx

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { getBrain, saveAd, type GeneratedAd } from "@/lib/storage";
1515
import { buildBrandSystemPrompt, type BrandBrain } from "@/lib/brand-brain";
1616
import { applySmartFill } from "@/lib/smart-fill";
1717
import { rememberLastGenerated, suggestNextSteps, type NextStep } from "@/lib/next-steps";
18+
import { ProviderSwitcher } from "@/components/ProviderSwitcher";
1819
import { providerSupportsVision, fileToImagePart, pickVisionProvider } from "@/lib/providers/vision";
1920
import { getActiveProviderId } from "@/lib/settings";
2021
import type { ContentPart, ImagePart } from "@/lib/providers/types";
@@ -661,24 +662,17 @@ const OutputArea = memo(function OutputArea<I extends Record<string, unknown>>({
661662
// provider. Generic "API key invalid" hint is removed for this case
662663
// because the key obviously works — they just exceeded the quota.
663664
return (
664-
<div className="border border-live/40 bg-live/5 p-5 space-y-2">
665+
<div className="border border-live/40 bg-live/5 p-5 space-y-3">
665666
<div className="text-[10px] font-mono uppercase tracking-ui-mega text-live flex items-center gap-2">
666-
<span className="h-1 w-1 bg-warn" /> rate-limit hit (free-tier quota)
667+
<span className="h-1 w-1 bg-live" /> rate-limit hit (free-tier quota)
667668
</div>
668669
<p className="text-sm text-ink leading-relaxed">
669670
Provider rejected with: <code className="text-[11px] font-mono text-live bg-base-900/60 px-1 py-0.5">{lastError}</code>
670671
</p>
671672
<p className="text-[12px] text-ink-muted leading-relaxed">
672-
Your API key works fine — you've hit the free-tier ceiling for this provider. Options:
673-
</p>
674-
<ul className="text-[12px] text-ink-muted list-disc list-inside space-y-0.5">
675-
<li>Wait the retry-in seconds shown in the error, then retry the same button.</li>
676-
<li>Switch to a different provider in <a href="/settings" className="text-live underline">Settings</a> — Groq + Cerebras have generous free tiers.</li>
677-
<li>Add billing on the same provider to upgrade past the free-tier cap.</li>
678-
</ul>
679-
<p className="text-[11px] font-mono uppercase tracking-ui-wide text-ink-subtle pt-1">
680-
see exact limits in settings → rate limits dropdown · or in the status bar at the bottom of the page
673+
Your API key works fine — you've hit the per-minute / daily quota. Either wait the retry-in seconds shown above, or switch provider now:
681674
</p>
675+
<ProviderSwitcher reason="rate-limit" />
682676
</div>
683677
);
684678
}

components/ProviderSwitcher.tsx

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import Link from "next/link";
5+
import { ArrowRight, Plus, Zap, CheckCircle2, ExternalLink } from "lucide-react";
6+
import { PROVIDERS, type Provider } from "@/lib/providers";
7+
import { getActiveProviderId, setActiveProviderId, getProviderKey, getProvidersWithKeys } from "@/lib/settings";
8+
import { getProviderLimits } from "@/lib/provider-limits";
9+
10+
/**
11+
* One-click provider switcher. Two surfaces:
12+
* - "inline" → rendered inside rate-limit error panels with actionable buttons
13+
* - "compact" → rendered in the StatusBar / page header as a dropdown
14+
*
15+
* Logic:
16+
* - Providers with saved keys ≠ active: instant-switch buttons.
17+
* - Providers without keys but with free tier: "Add free key" CTAs to /settings.
18+
* - Free-tier providers are highlighted with green/pos coloring.
19+
*/
20+
21+
interface Props {
22+
variant?: "inline" | "compact";
23+
/** Reason for the switcher to be visible — affects copy at top. */
24+
reason?: "rate-limit" | "default";
25+
onSwitch?: (providerId: string) => void;
26+
}
27+
28+
export function ProviderSwitcher({ variant = "inline", reason = "default", onSwitch }: Props) {
29+
const [activeId, setActiveId] = useState<string>("");
30+
const [configured, setConfigured] = useState<string[]>([]);
31+
32+
useEffect(() => {
33+
const refresh = () => {
34+
setActiveId(getActiveProviderId() ?? "");
35+
setConfigured(getProvidersWithKeys());
36+
};
37+
refresh();
38+
window.addEventListener("ados:provider-changed", refresh);
39+
window.addEventListener("storage", refresh);
40+
return () => {
41+
window.removeEventListener("ados:provider-changed", refresh);
42+
window.removeEventListener("storage", refresh);
43+
};
44+
}, []);
45+
46+
function switchTo(p: Provider) {
47+
setActiveProviderId(p.id);
48+
setActiveId(p.id);
49+
onSwitch?.(p.id);
50+
}
51+
52+
// Partition providers
53+
const others = PROVIDERS.filter((p) => p.id !== activeId);
54+
const otherConfigured = others.filter((p) => configured.includes(p.id));
55+
const otherUnconfigured = others.filter((p) => !configured.includes(p.id));
56+
// Free-tier providers we recommend first when no key is set
57+
const freeRecommendations = otherUnconfigured.filter((p) => getProviderLimits(p.id)?.has_free_tier);
58+
59+
if (variant === "compact") {
60+
return (
61+
<div className="flex items-center gap-1.5 flex-wrap">
62+
{otherConfigured.length > 0 ? (
63+
otherConfigured.slice(0, 3).map((p) => (
64+
<button
65+
key={p.id}
66+
onClick={() => switchTo(p)}
67+
className="text-[10px] font-mono uppercase tracking-ui-wide text-ink-muted hover:text-live transition border border-base-700 hover:border-live/40 px-1.5 py-0.5"
68+
title={`Switch active provider to ${p.name}`}
69+
>
70+
{p.name}
71+
</button>
72+
))
73+
) : (
74+
<Link href="/settings" className="text-[10px] font-mono uppercase tracking-ui-wide text-info hover:underline">
75+
+ add provider
76+
</Link>
77+
)}
78+
</div>
79+
);
80+
}
81+
82+
return (
83+
<div className="space-y-3">
84+
{otherConfigured.length > 0 ? (
85+
<div>
86+
<div className="text-[10px] font-mono uppercase tracking-ui-mega text-ink-faint mb-2">
87+
Switch to a configured provider · one click
88+
</div>
89+
<div className="grid sm:grid-cols-2 gap-2">
90+
{otherConfigured.map((p) => {
91+
const lim = getProviderLimits(p.id);
92+
const isFree = lim?.has_free_tier;
93+
return (
94+
<button
95+
key={p.id}
96+
onClick={() => switchTo(p)}
97+
className={`flex items-start gap-2 px-3 py-2.5 border text-left transition ${
98+
isFree
99+
? "border-pos/40 bg-pos/5 hover:bg-pos/10 hover:border-pos"
100+
: "border-base-600 bg-base-900/40 hover:bg-base-800/60 hover:border-base-500"
101+
}`}
102+
>
103+
<CheckCircle2 size={12} className={`shrink-0 mt-0.5 ${isFree ? "text-pos" : "text-live"}`} />
104+
<div className="flex-1 min-w-0">
105+
<div className="text-sm font-medium text-ink">{p.name}</div>
106+
{lim ? (
107+
<div className={`text-[10px] font-mono uppercase tracking-ui-wide ${isFree ? "text-pos" : "text-ink-faint"} mt-0.5`}>
108+
{isFree ? "FREE" : "PAID"} · {lim.summary.split("·").slice(0, 2).join("·").replace(/^FREE/, "").replace(/^Paid/, "").trim()}
109+
</div>
110+
) : null}
111+
</div>
112+
<ArrowRight size={11} className="text-ink-faint shrink-0 mt-1" />
113+
</button>
114+
);
115+
})}
116+
</div>
117+
</div>
118+
) : null}
119+
120+
{otherConfigured.length === 0 && freeRecommendations.length > 0 ? (
121+
<div>
122+
<div className="text-[10px] font-mono uppercase tracking-ui-mega text-pos mb-2 flex items-center gap-1.5">
123+
<Zap size={11} /> Add a free-tier provider in 30 seconds
124+
</div>
125+
<div className="grid sm:grid-cols-2 gap-2">
126+
{freeRecommendations.slice(0, 4).map((p) => {
127+
const lim = getProviderLimits(p.id)!;
128+
return (
129+
<Link
130+
key={p.id}
131+
href={`/settings#provider-${p.id}`}
132+
className="flex items-start gap-2 px-3 py-2.5 border border-pos/40 bg-pos/5 hover:bg-pos/10 hover:border-pos transition"
133+
>
134+
<Plus size={12} className="text-pos shrink-0 mt-0.5" />
135+
<div className="flex-1 min-w-0">
136+
<div className="text-sm font-medium text-ink">{p.name}</div>
137+
<div className="text-[10px] font-mono uppercase tracking-ui-wide text-pos mt-0.5">
138+
FREE · {lim.summary.split("·").slice(0, 2).join("·").replace(/^FREE/, "").trim()}
139+
</div>
140+
</div>
141+
<ExternalLink size={11} className="text-ink-faint shrink-0 mt-1" />
142+
</Link>
143+
);
144+
})}
145+
</div>
146+
<p className="text-[11px] text-ink-muted mt-2 leading-relaxed">
147+
Each one takes a free key (no credit card on Groq / Cerebras / Gemini / OpenRouter). You'll be in /settings to paste it and verify — back here in 30 seconds.
148+
</p>
149+
</div>
150+
) : null}
151+
152+
{otherConfigured.length > 0 && freeRecommendations.length > 0 ? (
153+
<div className="pt-2 border-t border-base-700">
154+
<Link href="/settings" className="text-[11px] font-mono uppercase tracking-ui-wide text-info hover:underline">
155+
+ add more providers in settings
156+
</Link>
157+
</div>
158+
) : null}
159+
160+
{otherConfigured.length === 0 && freeRecommendations.length === 0 ? (
161+
<Link href="/settings" className="btn-primary inline-flex">
162+
<Plus size={11} /> Add another provider in Settings
163+
</Link>
164+
) : null}
165+
</div>
166+
);
167+
}

components/StatusBar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getActiveProviderId, getActiveModelId, getUsage, hasAnyKeyConfigured, g
66
import { getProvider } from "@/lib/providers";
77
import { formatCost } from "@/lib/utils";
88
import { getProviderLimits } from "@/lib/provider-limits";
9+
import { ProviderSwitcher } from "@/components/ProviderSwitcher";
910

1011
export function StatusBar() {
1112
const [info, setInfo] = useState({
@@ -70,6 +71,7 @@ export function StatusBar() {
7071
<Cell>
7172
<span className="text-ink-faint">Provider</span>
7273
<Link href="/settings" className="text-ink hover:text-live transition font-medium">{info.providerName}</Link>
74+
<div className="hidden md:flex"><ProviderSwitcher variant="compact" /></div>
7375
</Cell>
7476
<Cell>
7577
<span className="text-ink-faint">Model</span>

0 commit comments

Comments
 (0)