From 7214f59d5759c6b0e1e58b8202c0ecf64c9a43f8 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 18 May 2026 02:23:16 -0700 Subject: [PATCH] studio: fix Connections dialog UX issues surfaced by image-gate probe (#5518) * studio: register /settings route that opens the settings dialog Navigating to /settings used to render Not Found because the route was never registered. The settings dialog only opened via the user menu, so /settings was a broken deep link if shared. Add a route that calls useSettingsDialogStore.openDialog() and redirects to the post-auth landing page so the modal appears on top of the chat. * studio: harden Connections dialog provider sync and allow manual model IDs Two related fixes for the Connections panel. 1. Keep localStorage providers when the server returns an empty list. The dialog used to sync from /api/providers/ on mount and unconditionally overwrite the Zustand provider store with the server result. When the server had no enabled configs but the local store had entries (legacy users, fresh dev installs, or providers created via earlier paths), opening the dialog silently wiped them. The model picker reads from the same store, so the chat header reverted from 'gpt-4o . OpenAI' to the raw 'external::openai-1::gpt-4o' key. Treat the server as authoritative only when it actually has rows; otherwise keep the local view. 2. Accept manual model IDs alongside the live catalog for remote-mode providers (DeepSeek, OpenAI, etc.). Previously the only way to save was to load the available-models catalog via a live API call, which fails in air-gapped setups, behind 502s, or when the user already knows the exact model ID. Add a Textarea fallback in the same render block, and relax the validation to accept manual IDs even when availableModels is empty. The validation message now points users at the manual path. * studio: restrict manual model ID entry to openrouter among remote providers Address review feedback: major remote providers (openai, anthropic, gemini, mistral, cohere, deepseek, ...) expose large per-model parameter surfaces that differ across models, so accepting pasted model IDs leads to mismatched parameter expectations and frustrating runtime errors. Keep their catalog curated by hiding the manual textarea and falling back to the prior 'Load available models first' validation toast for them. OpenRouter drops unsupported parameters server-side, so manual entry remains useful there; keep the textarea and the union save path for it. Custom and curated backends already gated via isCustomProvider / isCuratedModelList and continue to require manual entry as before. * studio: shorten code comments in chat-providers-dialog.tsx Trim three multi-line comment blocks to single lines per review. --- studio/frontend/src/app/router.tsx | 2 + studio/frontend/src/app/routes/settings.tsx | 20 ++ .../features/chat/chat-providers-dialog.tsx | 200 +++++++++++------- 3 files changed, 149 insertions(+), 73 deletions(-) create mode 100644 studio/frontend/src/app/routes/settings.tsx diff --git a/studio/frontend/src/app/router.tsx b/studio/frontend/src/app/router.tsx index 13ff8a5cbe..d26d8b9dee 100644 --- a/studio/frontend/src/app/router.tsx +++ b/studio/frontend/src/app/router.tsx @@ -12,6 +12,7 @@ import { Route as indexRoute } from "./routes/index"; import { Route as loginRoute } from "./routes/login"; import { Route as onboardingRoute } from "./routes/onboarding"; import { Route as changePasswordRoute } from "./routes/change-password"; +import { Route as settingsRoute } from "./routes/settings"; import { Route as studioRoute } from "./routes/studio"; const routeTree = rootRoute.addChildren([ @@ -20,6 +21,7 @@ const routeTree = rootRoute.addChildren([ loginRoute, changePasswordRoute, gridTestRoute, + settingsRoute, studioRoute, chatRoute, exportRoute, diff --git a/studio/frontend/src/app/routes/settings.tsx b/studio/frontend/src/app/routes/settings.tsx new file mode 100644 index 0000000000..4e35f0b16d --- /dev/null +++ b/studio/frontend/src/app/routes/settings.tsx @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { createRoute, redirect } from "@tanstack/react-router"; +import { getPostAuthRoute } from "@/features/auth"; +import { useSettingsDialogStore } from "@/features/settings"; +import { requireAuth } from "../auth-guards"; +import { Route as rootRoute } from "./__root"; + +// /settings is a deep link to the modal. Open it, then redirect home. +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: "/settings", + beforeLoad: async () => { + await requireAuth(); + useSettingsDialogStore.getState().openDialog(); + throw redirect({ to: getPostAuthRoute() }); + }, + component: () => null, +}); diff --git a/studio/frontend/src/features/chat/chat-providers-dialog.tsx b/studio/frontend/src/features/chat/chat-providers-dialog.tsx index 96f95d6d7b..722716cbc9 100644 --- a/studio/frontend/src/features/chat/chat-providers-dialog.tsx +++ b/studio/frontend/src/features/chat/chat-providers-dialog.tsx @@ -135,6 +135,9 @@ function parseManualModelIds(text: string): string[] { return out; } +// Remote providers safe for manual model IDs (openrouter drops unused params). +const MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES = new Set(["openrouter"]); + function pruneProviderModelIds(providerType: string, modelIds: string[]): string[] { if (providerType === "anthropic") { return modelIds.filter((id) => !ANTHROPIC_DATED_SNAPSHOT_SUFFIX.test(id)); @@ -330,6 +333,10 @@ export function ChatProvidersSettings({ updatedAt, }; }); + // Don't wipe localStorage providers when the server has no rows. + if (syncedProviders.length === 0 && providersRef.current.length > 0) { + return; + } onProvidersChange(syncedProviders); } catch (error) { const message = @@ -501,19 +508,28 @@ export function ChatProvidersSettings({ return; } const curated = selectedRegistryEntry?.model_list_mode === "curated"; - const manualModels = isCustomProvider || curated; + const manualOnly = isCustomProvider || curated; + const remoteAllowsManual = + MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(providerType); + const manualIds = parseManualModelIds(manualModelIds); + const allowManual = manualOnly || remoteAllowsManual; const modelsToSave = pruneProviderModelIds( providerType, - manualModels + allowManual ? [ ...new Set([ ...selectedModelIds, - ...parseManualModelIds(manualModelIds), + ...manualIds, ]), ] : [...selectedModelIds], ); - if (manualModels) { + if (manualOnly) { + if (modelsToSave.length === 0) { + toast.error("Add at least one model ID."); + return; + } + } else if (remoteAllowsManual && manualIds.length > 0) { if (modelsToSave.length === 0) { toast.error("Add at least one model ID."); return; @@ -553,7 +569,7 @@ export function ChatProvidersSettings({ name: created.display_name, baseUrl: created.base_url ?? "", models: modelsToSave, - availableModels: manualModels + availableModels: manualOnly ? [] : pruneProviderModelIds(providerType, availableModels), isReasoningModel: supportsProviderReasoningToggle(uiProviderType) @@ -597,19 +613,29 @@ export function ChatProvidersSettings({ } const entry = registryByType.get(existing.providerType); const curated = entry?.model_list_mode === "curated"; - const manualModels = isEditingCustomProvider || curated; + const manualOnly = isEditingCustomProvider || curated; + const remoteAllowsManual = MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has( + existing.providerType, + ); + const manualIds = parseManualModelIds(manualModelIds); + const allowManual = manualOnly || remoteAllowsManual; const modelsToSave = pruneProviderModelIds( existing.providerType, - manualModels + allowManual ? [ ...new Set([ ...selectedModelIds, - ...parseManualModelIds(manualModelIds), + ...manualIds, ]), ] : [...selectedModelIds], ); - if (manualModels) { + if (manualOnly) { + if (modelsToSave.length === 0) { + toast.error("Add at least one model ID."); + return; + } + } else if (remoteAllowsManual && manualIds.length > 0) { if (modelsToSave.length === 0) { toast.error("Add at least one model ID."); return; @@ -655,7 +681,7 @@ export function ChatProvidersSettings({ name: updated.display_name, baseUrl: updated.base_url ?? "", models: modelsToSave, - availableModels: manualModels + availableModels: manualOnly ? [] : pruneProviderModelIds(existing.providerType, availableModels), isReasoningModel: supportsProviderReasoningToggle( @@ -1184,71 +1210,99 @@ export function ChatProvidersSettings({ /> - ) : availableModels.length === 0 ? null : ( + ) : availableModels.length === 0 && + !MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(providerType) ? null : (
-
- - {availableModelsLabel} - - - setModelSearchQuery(event.target.value) - } - placeholder="Search" - aria-label="Search models" - className={modelSearchInputClassName} - /> -
- - -
-
-
    - {filteredAvailableModels.length === 0 ? ( -
  • - No matching models -
  • - ) : ( - filteredAvailableModels.map((model, index) => ( -
  • toggleModel(model)} - > - toggleModel(model)} - onClick={(event) => event.stopPropagation()} - /> - +
    + + {availableModelsLabel} + + + setModelSearchQuery(event.target.value) + } + placeholder="Search" + aria-label="Search models" + className={modelSearchInputClassName} + /> +
    +
  • - )) - )} -
+ Select all + + +
+ + + + )} + {/* Manual IDs allowed for openrouter only. */} + {MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(providerType) ? ( +
+ +