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.
This commit is contained in:
Daniel Han 2026-05-18 02:23:16 -07:00 committed by GitHub
commit 7214f59d57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 148 additions and 72 deletions

View file

@ -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,

View file

@ -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,
});

View file

@ -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<string>(["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({
/>
</div>
</div>
) : availableModels.length === 0 ? null : (
) : availableModels.length === 0 &&
!MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(providerType) ? null : (
<div className="space-y-3 px-4 py-4">
<div className="grid grid-cols-[112px_minmax(220px,330px)_auto] items-center gap-3 max-sm:grid-cols-1">
<span className="whitespace-nowrap text-xs font-medium text-muted-foreground">
{availableModelsLabel}
</span>
<Input
id={`provider-model-search-${modelsPanelKey}`}
type="search"
value={modelSearchQuery}
onChange={(event) =>
setModelSearchQuery(event.target.value)
}
placeholder="Search"
aria-label="Search models"
className={modelSearchInputClassName}
/>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs font-medium text-foreground/80 hover:bg-muted/45"
onClick={selectAllModels}
>
Select all
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs font-medium text-foreground/80 hover:bg-muted/45"
onClick={clearModelSelection}
>
Clear
</Button>
</div>
</div>
<ul className="max-h-56 overflow-y-auto rounded-[8px] border border-border/70 bg-background/50">
{filteredAvailableModels.length === 0 ? (
<li className="px-3 py-3 text-xs text-muted-foreground">
No matching models
</li>
) : (
filteredAvailableModels.map((model, index) => (
<li
key={model}
className="flex cursor-pointer items-center gap-2.5 border-border/60 border-b px-3 py-2 last:border-b-0 hover:bg-muted/35"
onClick={() => toggleModel(model)}
>
<Checkbox
id={`provider-model-remote-${modelsPanelKey}-${index}`}
checked={selectedModelIds.includes(model)}
onCheckedChange={() => toggleModel(model)}
onClick={(event) => event.stopPropagation()}
/>
<span
className="min-w-0 break-all text-sm leading-tight"
{availableModels.length === 0 ? null : (
<>
<div className="grid grid-cols-[112px_minmax(220px,330px)_auto] items-center gap-3 max-sm:grid-cols-1">
<span className="whitespace-nowrap text-xs font-medium text-muted-foreground">
{availableModelsLabel}
</span>
<Input
id={`provider-model-search-${modelsPanelKey}`}
type="search"
value={modelSearchQuery}
onChange={(event) =>
setModelSearchQuery(event.target.value)
}
placeholder="Search"
aria-label="Search models"
className={modelSearchInputClassName}
/>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs font-medium text-foreground/80 hover:bg-muted/45"
onClick={selectAllModels}
>
{model}
</span>
</li>
))
)}
</ul>
Select all
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs font-medium text-foreground/80 hover:bg-muted/45"
onClick={clearModelSelection}
>
Clear
</Button>
</div>
</div>
<ul className="max-h-56 overflow-y-auto rounded-[8px] border border-border/70 bg-background/50">
{filteredAvailableModels.length === 0 ? (
<li className="px-3 py-3 text-xs text-muted-foreground">
No matching models
</li>
) : (
filteredAvailableModels.map((model, index) => (
<li
key={model}
className="flex cursor-pointer items-center gap-2.5 border-border/60 border-b px-3 py-2 last:border-b-0 hover:bg-muted/35"
onClick={() => toggleModel(model)}
>
<Checkbox
id={`provider-model-remote-${modelsPanelKey}-${index}`}
checked={selectedModelIds.includes(model)}
onCheckedChange={() => toggleModel(model)}
onClick={(event) => event.stopPropagation()}
/>
<span
className="min-w-0 break-all text-sm leading-tight"
>
{model}
</span>
</li>
))
)}
</ul>
</>
)}
{/* Manual IDs allowed for openrouter only. */}
{MANUAL_MODEL_ID_REMOTE_PROVIDER_TYPES.has(providerType) ? (
<div className="space-y-2">
<Label
htmlFor="provider-manual-models"
className="text-sm font-medium"
>
{availableModels.length === 0
? "Or enter model IDs manually (one per line or comma-separated)"
: "Additional model IDs (one per line or comma-separated)"}
</Label>
<Textarea
id="provider-manual-models"
value={manualModelIds}
onChange={(event) =>
setManualModelIds(event.target.value)
}
placeholder={"model-id-1\nmodel-id-2"}
rows={4}
className="min-h-[80px] resize-y font-mono text-sm"
/>
</div>
) : null}
</div>
)}
</motion.div>