feat: polish providers settings form UI

This commit is contained in:
imagineer99 2026-05-07 16:12:55 +01:00
commit 5816c409a1
7 changed files with 764 additions and 435 deletions

View file

@ -15,6 +15,7 @@ import { useChooseNativeModel } from "@/features/native-intents/use-native-dialo
import { useNativeModelDrop } from "@/features/native-intents/use-native-drop";
import { useNativePathLeasesSupported } from "@/features/native-intents/use-native-readiness";
import { useNativeIntentStore } from "@/features/native-intents/store";
import { useSettingsDialogStore } from "@/features/settings";
import type { NativeIntent } from "@/features/native-intents/types";
import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils";
@ -37,18 +38,11 @@ import {
import { toast } from "sonner";
import type { ChatSearch } from "@/app/routes/chat";
import { listLocalModels } from "./api/chat-api";
import { ChatProvidersDialog } from "./chat-providers-dialog";
import { ChatSettingsPanel } from "./chat-settings-sheet";
import { ContextUsageBar } from "./components/context-usage-bar";
import { ModelLoadInlineStatus } from "./components/model-load-status";
import { db } from "./db";
import {
buildExternalModelId,
isExternalModelId,
loadExternalProviders,
saveExternalProviders,
type ExternalProviderConfig,
} from "./external-providers";
import { buildExternalModelId, isExternalModelId } from "./external-providers";
import { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
import {
clearTrainingCompareHandoff,
@ -63,6 +57,7 @@ import {
SharedComposer,
} from "./shared-composer";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import { buildChatTourSteps } from "./tour";
import type { ChatView, MessageRecord } from "./types";
@ -545,7 +540,7 @@ export function ChatPage(): ReactElement {
const settingsOpen = useChatRuntimeStore((s) => s.settingsPanelOpen);
const setSettingsOpen = useChatRuntimeStore((s) => s.setSettingsPanelOpen);
const [providersOpen, setProvidersOpen] = useState(false);
const externalProviders = useExternalProvidersStore((s) => s.providers);
useEffect(() => {
const threadId = search.thread;
@ -576,12 +571,9 @@ export function ChatPage(): ReactElement {
canceled = true;
};
}, [navigate, search.thread]);
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
const [externalProviders, setExternalProviders] = useState<ExternalProviderConfig[]>(() =>
loadExternalProviders(),
);
const viewBeforeCompareRef = useRef<ChatSearch | null>(null);
const inferenceParams = useChatRuntimeStore((state) => state.params);
const setInferenceParams = useChatRuntimeStore((state) => state.setParams);
@ -609,7 +601,9 @@ export function ChatPage(): ReactElement {
loadProgress,
loadToastDismissed,
} = useChatModelRuntime();
const pendingNativeModelIntent = useNativeIntentStore((state) => state.pendingModelIntent);
const pendingNativeModelIntent = useNativeIntentStore(
(state) => state.pendingModelIntent,
);
const nativePathLeasesSupported = useNativePathLeasesSupported();
const refreshRef = useRef(refresh);
const selectModelRef = useRef(selectModel);
@ -649,7 +643,8 @@ export function ChatPage(): ReactElement {
const hasActiveModel = Boolean(inferenceParams.checkpoint);
const loadNativeModelIntent = useCallback(
async (intent: NativeIntent, loadingDescription: string) => {
const label = intent.path.displayLabel || intent.displayLabel || "Local GGUF model";
const label =
intent.path.displayLabel || intent.displayLabel || "Local GGUF model";
await selectModel({
id: label,
nativePathToken: intent.path.token,
@ -885,20 +880,24 @@ export function ChatPage(): ReactElement {
.catch(() => {});
}, [navigate]);
const refreshModelLists = useCallback((deletedModel?: DeletedModelRef) => {
const { checkpoint } = useChatRuntimeStore.getState().params;
const activeGgufVariant = useChatRuntimeStore.getState().activeGgufVariant;
if (
modelMatchesDeleted(
{ id: checkpoint, ggufVariant: activeGgufVariant },
deletedModel,
)
) {
useChatRuntimeStore.getState().clearCheckpoint();
}
void refresh();
refreshLocalModels();
}, [refresh, refreshLocalModels]);
const refreshModelLists = useCallback(
(deletedModel?: DeletedModelRef) => {
const { checkpoint } = useChatRuntimeStore.getState().params;
const activeGgufVariant =
useChatRuntimeStore.getState().activeGgufVariant;
if (
modelMatchesDeleted(
{ id: checkpoint, ggufVariant: activeGgufVariant },
deletedModel,
)
) {
useChatRuntimeStore.getState().clearCheckpoint();
}
void refresh();
refreshLocalModels();
},
[refresh, refreshLocalModels],
);
const loraModels = useMemo<LoraModelOption[]>(() => {
const fromLoras = lorasFromStore.map((lora) => ({
@ -918,10 +917,6 @@ export function ChatPage(): ReactElement {
refreshLocalModels();
}, [refresh, refreshLocalModels]);
useEffect(() => {
void saveExternalProviders(externalProviders);
}, [externalProviders]);
useEffect(() => {
const handoff = getTrainingCompareHandoff();
if (!handoff) return;
@ -1115,7 +1110,11 @@ export function ChatPage(): ReactElement {
<TooltipPrimitive.Trigger asChild>
<button
type="button"
onClick={() => setProvidersOpen(true)}
onClick={() =>
useSettingsDialogStore
.getState()
.openDialog("connections")
}
className="flex h-[34px] w-[34px] items-center justify-center rounded-[8px] text-[#383835] transition-colors hover:bg-[#ececec] hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:text-[#c7c7c4] dark:hover:bg-[#2e3035] dark:hover:text-white"
aria-label="Open API providers"
>
@ -1186,12 +1185,6 @@ export function ChatPage(): ReactElement {
}
}}
/>
<ChatProvidersDialog
open={providersOpen}
onOpenChange={setProvidersOpen}
providers={externalProviders}
onProvidersChange={setExternalProviders}
/>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -168,9 +168,9 @@ function saveRawKeyMap(map: Record<string, string>): void {
}
}
export async function saveExternalProviders(
export function saveExternalProviders(
providers: ExternalProviderConfig[],
): Promise<void> {
): void {
if (!canUseStorage()) return;
try {
localStorage.setItem(EXTERNAL_PROVIDERS_KEY, JSON.stringify(providers));
@ -223,4 +223,3 @@ export function removeExternalProviderApiKey(providerId: string): void {
// ignore
}
}

View file

@ -0,0 +1,24 @@
// 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 { create } from "zustand";
import {
loadExternalProviders,
saveExternalProviders,
type ExternalProviderConfig,
} from "../external-providers";
interface ExternalProvidersState {
providers: ExternalProviderConfig[];
setProviders: (providers: ExternalProviderConfig[]) => void;
}
export const useExternalProvidersStore = create<ExternalProvidersState>(
(set) => ({
providers: loadExternalProviders(),
setProviders: (providers) => {
set({ providers });
saveExternalProviders(providers);
},
}),
);

View file

@ -10,6 +10,7 @@ import {
import { cn } from "@/lib/utils";
import {
Cancel01Icon,
CloudIcon,
Globe02Icon,
HelpCircleIcon,
Message01Icon,
@ -20,11 +21,15 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useRef } from "react";
import { useSettingsDialogStore, type SettingsTab } from "./stores/settings-dialog-store";
import {
useSettingsDialogStore,
type SettingsTab,
} from "./stores/settings-dialog-store";
import { AboutTab } from "./tabs/about-tab";
import { ApiKeysTab } from "./tabs/api-keys-tab";
import { AppearanceTab } from "./tabs/appearance-tab";
import { ChatTab } from "./tabs/chat-tab";
import { ConnectionsTab } from "./tabs/connections-tab";
import { GeneralTab } from "./tabs/general-tab";
import { ProfileTab } from "./tabs/profile-tab";
@ -40,6 +45,7 @@ const TABS: TabDef[] = [
{ id: "profile", label: "Profile", icon: UserIcon },
{ id: "appearance", label: "Appearance", icon: PaintBrush02Icon },
{ id: "chat", label: "Chat", icon: Message01Icon },
{ id: "connections", label: "Providers", icon: CloudIcon },
{ id: "api-keys", label: "API", icon: Globe02Icon, badge: "New" },
{ id: "about", label: "Help", icon: HelpCircleIcon },
];
@ -54,6 +60,8 @@ function renderTab(tab: SettingsTab) {
return <AppearanceTab />;
case "chat":
return <ChatTab />;
case "connections":
return <ConnectionsTab />;
case "api-keys":
return <ApiKeysTab />;
case "about":
@ -72,6 +80,7 @@ export function SettingsDialog() {
profile: null,
appearance: null,
chat: null,
connections: null,
"api-keys": null,
about: null,
});
@ -100,9 +109,9 @@ export function SettingsDialog() {
<DialogDescription className="sr-only">
Manage your Unsloth Studio preferences.
</DialogDescription>
<div className="flex h-full min-h-0">
<aside className="font-heading flex w-[200px] shrink-0 flex-col border-r border-border bg-muted/20 p-2">
<nav className="flex flex-col gap-0.5">
<div className="flex h-full min-h-0 max-sm:flex-col">
<aside className="font-heading flex w-[200px] shrink-0 flex-col border-r border-border bg-muted/20 p-2 max-sm:w-full max-sm:border-r-0 max-sm:border-b">
<nav className="flex flex-col gap-0.5 max-sm:flex-row max-sm:overflow-x-auto">
{TABS.map((tab) => {
const active = activeTab === tab.id;
return (
@ -115,6 +124,7 @@ export function SettingsDialog() {
onClick={() => setActiveTab(tab.id)}
className={cn(
"relative flex h-[30px] items-center gap-2.5 rounded-[8px] px-2.5 text-sm font-medium transition-colors",
"max-sm:shrink-0",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
active
? "text-black dark:text-white"
@ -142,7 +152,9 @@ export function SettingsDialog() {
strokeWidth={1.5}
className="relative z-10 size-[18px]"
/>
<span className="relative z-10 min-w-0 truncate">{tab.label}</span>
<span className="relative z-10 min-w-0 truncate">
{tab.label}
</span>
{tab.badge ? (
<span className="relative z-10 ml-auto rounded-[6px] border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] leading-none font-semibold text-emerald-700 dark:text-emerald-300">
{tab.badge}
@ -154,7 +166,7 @@ export function SettingsDialog() {
</nav>
</aside>
<main className="relative flex min-w-0 flex-1 flex-col">
<main className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<button
type="button"
onClick={closeDialog}

View file

@ -8,6 +8,7 @@ export type SettingsTab =
| "profile"
| "appearance"
| "chat"
| "connections"
| "api-keys"
| "about";
@ -29,8 +30,18 @@ function loadInitialTab(): SettingsTab {
} catch {
return "general";
}
const valid: SettingsTab[] = ["general", "profile", "appearance", "chat", "api-keys", "about"];
return valid.includes(stored as SettingsTab) ? (stored as SettingsTab) : "general";
const valid: SettingsTab[] = [
"general",
"profile",
"appearance",
"chat",
"connections",
"api-keys",
"about",
];
return valid.includes(stored as SettingsTab)
? (stored as SettingsTab)
: "general";
}
export const useSettingsDialogStore = create<SettingsDialogState>((set) => ({

View file

@ -0,0 +1,17 @@
// 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 { ChatProvidersSettings } from "@/features/chat/chat-providers-dialog";
import { useExternalProvidersStore } from "@/features/chat/stores/external-providers-store";
export function ConnectionsTab() {
const providers = useExternalProvidersStore((s) => s.providers);
const setProviders = useExternalProvidersStore((s) => s.setProviders);
return (
<ChatProvidersSettings
providers={providers}
onProvidersChange={setProviders}
/>
);
}