nyxcore-systems
7 min read

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.

ReactTypeScriptTRPCRefactoringUI/UXLLMDevOpsDockerFrontend Architecture

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 availableProviders with a trpc query and manage selectedProvider and selectedModel state.

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):

  1. useAvailableProviders() Hook:

    • We created a new custom React hook, useAvailableProviders(), living in src/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 staleTime of 60 seconds. This means that once fetched, the data is considered fresh for a minute, reducing unnecessary network requests while still ensuring eventual consistency.
  2. Intelligent ProviderModelPicker:

    • The ProviderModelPicker component itself was enhanced. If it receives a providers prop, it uses that data.
    • However, if no providers prop is passed, it automatically calls useAvailableProviders() internally to fetch the data itself! This "self-fetching" capability is what makes it so powerful.
  3. Unified TRPC Endpoint:

    • We added availableProviders procedure to src/server/trpc/routers/dashboard.ts. This mirrors an older procedure, ensuring all client calls now hit a canonical, shared endpoint.

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.

typescript
// 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 from selectedProvider and selectedModel into a unified generationTarget: ProviderModelSelection object. 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 two ProviderModelPicker instances. The fallback picker even cleverly used providers={fallbackProviders} to exclude the primary selection, demonstrating the component's flexibility.
  • evaluations/page.tsx: Simplified by removing an explicit providerData query.
  • discussions/new/page.tsx & projects/[id]/page.tsx: These pages still needed multi-provider selection, which the current single-select ProviderModelPicker doesn't support. However, we still upgraded their internal data fetching to use the new useAvailableProviders() hook, centralizing the source of truth.
  • src/components/discussion/provider-picker.tsx (OLD): Even this older, soon-to-be-deprecated component was updated to use useAvailableProviders() 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 39.99payment,withonly39.99 payment, with only 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

  1. Docker Module Resolution Surprises:

    • Challenge: My initial test script, copied to /tmp/test-anthropic.js inside the Docker container, failed with Cannot find module '@prisma/client'.
    • Root Cause: The container's node_modules directory was at /app/, not globally accessible or within /tmp.
    • Takeaway: Always be mindful of the WORKDIR and module resolution paths within your Docker containers. For quick scripts needing dependencies, copy them into the WORKDIR (e.g., /app/) where node_modules are present.
  2. Bash/SSH Escaping Hell (! Character):

    • Challenge: When trying to run a simple node -e 'if (!key) { ... }' command directly via SSH, the ! character in !key was 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, scp it to the server, and then docker cp it into the container. It saves a lot of headache.
  3. Crypto Format Quirks: v1:iv:tag:data vs iv: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 v1 prefix, 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.

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 ProviderPicker in discussions/[id] and workflows/[id] with the new ProviderModelPicker (this will require some thought on defaultOpen/onClose props or a slight UX rethink).
  • Delete src/components/discussion/provider-picker.tsx once all its consumers are migrated.
  • Remove the discussions.availableProviders server 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!

json
{
  "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"
  ]
}