The Great UI Consolidation: Taming Provider/Model Selection with a Self-Fetching Component
We tackled UI fragmentation head-on by consolidating our LLM provider and model selection into a single, self-fetching React component, slashing boilerplate and boosting consistency across our app.
As developers, we've all been there: a feature grows, new pages are added, and suddenly, you have five different ways to do the exact same thing. For us at nyxCore, that "thing" was selecting an LLM provider and model. Across our dashboard, various pages had their own bespoke UI for this crucial interaction, leading to fragmented user experience, duplicated code, and a growing maintenance headache.
This past week, we drew a line in the sand. Our mission: Consolidate all provider/model selection UI across nyxCore to use a single, shared, self-fetching ProviderModelPicker component. I'm thrilled to report: mission accomplished!
The Problem: UI Sprawl and Duplication
Imagine you're building a new Persona or setting up an Evaluation. You need to pick an LLM provider (like OpenAI, Anthropic, etc.) and then a specific model (GPT-4, Claude 3 Opus, etc.). Initially, each new feature or page would implement this selection logic independently:
- One page might use a dropdown for providers and a grid of buttons for models.
- Another might have two separate
<select>elements. - Yet another might manually fetch
availableProviderswith atrpcquery and manageselectedProviderandselectedModelstate.
This redundancy was costing us. Every time a new provider was added, or a selection logic changed, we had to update multiple places. The user experience was inconsistent, and our codebase was getting heavier with unnecessary boilerplate.
The Solution: A Self-Fetching ProviderModelPicker
Our answer was to centralize. We already had a ProviderModelPicker component, but it relied on parent components to fetch and pass in the providers data. The real game-changer was making it self-fetching.
Here's the core architecture we landed on (1130afa):
-
useAvailableProviders()Hook:- We created a new custom React hook,
useAvailableProviders(), living insrc/components/shared/provider-model-picker.tsx. - This hook internally leverages
trpc.dashboard.availableProviders.useQuery()to fetch the list of available providers. - Crucially, it uses a
staleTimeof 60 seconds. This means that once fetched, the data is considered fresh for a minute, reducing unnecessary network requests while still ensuring eventual consistency.
- We created a new custom React hook,
-
Intelligent
ProviderModelPicker:- The
ProviderModelPickercomponent itself was enhanced. If it receives aprovidersprop, it uses that data. - However, if no
providersprop is passed, it automatically callsuseAvailableProviders()internally to fetch the data itself! This "self-fetching" capability is what makes it so powerful.
- The
-
Unified TRPC Endpoint:
- We added
availableProvidersprocedure tosrc/server/trpc/routers/dashboard.ts. This mirrors an older procedure, ensuring all client calls now hit a canonical, shared endpoint.
- We added
This pattern allowed us to dramatically simplify consumer pages. Instead of each page managing its own provider data fetching and state, they could now just drop in <ProviderModelPicker> and connect its onChange handler.
// Before (conceptual example)
const { data: providerData } = trpc.discussions.availableProviders.useQuery();
const [selectedProvider, setSelectedProvider] = useState<LLMProviderName | null>(null);
const [selectedModel, setSelectedModel] = useState<string | null>(null);
// ... lots of JSX for buttons and state management ...
// After (simplified)
const [generationTarget, setGenerationTarget] = useState<ProviderModelSelection | null>(null);
<ProviderModelPicker
value={generationTarget}
onChange={setGenerationTarget}
// No need to pass 'providers' prop! It fetches them itself.
/>
The Migration Journey: Slashing Lines and Boosting Consistency
The rollout was comprehensive, touching six different consumer pages:
personas/new/page.tsx: This was a huge win. We replaced approximately 80 lines of custom provider button grids and model button grids with a single<ProviderModelPicker>. State management was consolidated fromselectedProviderandselectedModelinto a unifiedgenerationTarget: ProviderModelSelectionobject. Clean!admin/page.tsx: This page featured two separate provider/model selections (for primary and fallback targets). We replaced about 150 lines of native<select>elements and model button grids with twoProviderModelPickerinstances. The fallback picker even cleverly usedproviders={fallbackProviders}to exclude the primary selection, demonstrating the component's flexibility.evaluations/page.tsx: Simplified by removing an explicitproviderDataquery.discussions/new/page.tsx&projects/[id]/page.tsx: These pages still needed multi-provider selection, which the current single-selectProviderModelPickerdoesn't support. However, we still upgraded their internal data fetching to use the newuseAvailableProviders()hook, centralizing the source of truth.src/components/discussion/provider-picker.tsx(OLD): Even this older, soon-to-be-deprecated component was updated to useuseAvailableProviders()internally, ensuring consistency in data fetching during the transition.
The net result? A satisfying -162 lines of code, a much cleaner codebase, and a significantly more consistent user experience across the application.
A Quick Detour: Anthropic API Key Validation
While the UI consolidation was the main event, a related task involved verifying our production Anthropic API key. This led to a few interesting debugging adventures:
Our production Anthropic key was showing a "credit balance is too low" error (we had a pending 6.74 available). To confirm everything else was working as expected, I needed to run a quick test script directly in the production container.
This seemingly simple task unearthed some classic developer "pain points":
Lessons Learned from the Trenches
-
Docker Module Resolution Surprises:
- Challenge: My initial test script, copied to
/tmp/test-anthropic.jsinside the Docker container, failed withCannot find module '@prisma/client'. - Root Cause: The container's
node_modulesdirectory was at/app/, not globally accessible or within/tmp. - Takeaway: Always be mindful of the
WORKDIRand module resolution paths within your Docker containers. For quick scripts needing dependencies, copy them into theWORKDIR(e.g.,/app/) wherenode_modulesare present.
- Challenge: My initial test script, copied to
-
Bash/SSH Escaping Hell (
!Character):- Challenge: When trying to run a simple
node -e 'if (!key) { ... }'command directly via SSH, the!character in!keywas getting corrupted by Bash/SSH escaping rules. - Root Cause:
!is a special character in Bash (history expansion). Unless properly escaped or quoted, it can lead to unexpected command execution or syntax errors. - Takeaway: For anything beyond the simplest inline commands, especially involving special characters, it's safer to write your script to a local file,
scpit to the server, and thendocker cpit into the container. It saves a lot of headache.
- Challenge: When trying to run a simple
-
Crypto Format Quirks:
v1:iv:tag:datavsiv:tag:data:- Challenge: Our decryption function, when parsing the encrypted API key, expected a 3-part format (
iv:tag:data). It failed with "Invalid initialization vector." - Root Cause: The actual encrypted string format used a
v1prefix, making it a 4-part string:v1:iv:tag:data. - Takeaway: When dealing with encrypted data, especially across systems or versions, always confirm the exact format, including any version prefixes or delimiters. A simple destructuring change
[, ivHex, tagHex, encHex](skipping the first part) fixed it.
- Challenge: Our decryption function, when parsing the encrypted API key, expected a 3-part format (
These small battles, though frustrating in the moment, are invaluable learning experiences that sharpen our debugging skills and deepen our understanding of our infrastructure.
The Road Ahead
With the ProviderModelPicker consolidation complete and deployed, and our Anthropic API key verified (pending credit clearance), here's what's immediately next on our plate:
- Verify Anthropic works after the $39.99 payment clears – rerun the test script on production.
- Replace the old
ProviderPickerindiscussions/[id]andworkflows/[id]with the newProviderModelPicker(this will require some thought ondefaultOpen/onCloseprops or a slight UX rethink). - Delete
src/components/discussion/provider-picker.tsxonce all its consumers are migrated. - Remove the
discussions.availableProvidersserver procedure, as it now has zero client consumers. - Run persona evaluations with Anthropic once credits are active.
Refactoring and unifying components like this is a continuous effort, but the immediate gains in developer experience and UI consistency are already palpable. Here's to cleaner code and happier users!
{
"thingsDone": [
"Consolidated LLM provider/model selection UI to use shared ProviderModelPicker component",
"Implemented self-fetching logic for ProviderModelPicker using useAvailableProviders() hook and TRPC",
"Migrated 6 consumer pages (evaluations, personas/new, admin, discussions/new, projects/[id], old discussion/provider-picker)",
"Achieved -162 lines net code reduction across migrations",
"Tested production Anthropic API key, confirmed decryption and temporary credit balance issue",
"Deployed commit 1130afa to production"
],
"pains": [
"Docker module resolution issues when running scripts in /tmp",
"Bash/SSH escaping problems with '!' character in inline node commands",
"Incorrect assumption about crypto format (3-part vs 4-part with version prefix)"
],
"successes": [
"Significant reduction in UI boilerplate and improved consistency",
"Successful deployment of a major UI refactor",
"Centralized data fetching for LLM providers",
"Troubleshooting and resolving critical API key testing issues in production environment"
],
"techStack": [
"React",
"TypeScript",
"Next.js",
"TRPC",
"Docker",
"LLM APIs (Anthropic)",
"Prisma",
"SSH",
"Node.js"
]
}