From c52e26f78814fdc0edb40bd210415a173bb45205 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:02:26 +0800 Subject: [PATCH] fix(webui): route first-run model setup choices --- webui/src/App.tsx | 24 +++- .../src/components/settings/SettingsPage.tsx | 4 + .../src/components/settings/SettingsView.tsx | 4 + .../settings/models/ProviderSettings.tsx | 114 +++++++++++++----- .../components/thread/ModelSetupDialog.tsx | 27 +++-- .../src/components/thread/ThreadComposer.tsx | 12 +- webui/src/components/thread/ThreadShell.tsx | 33 ++--- webui/src/lib/model-setup.ts | 48 ++++++++ webui/src/tests/app-layout.test.tsx | 61 ++++++++++ webui/src/tests/settings-providers.test.tsx | 42 +++++++ webui/src/tests/settings-test-utils.tsx | 3 + webui/src/tests/thread-shell.test.tsx | 30 ++++- 12 files changed, 329 insertions(+), 73 deletions(-) create mode 100644 webui/src/lib/model-setup.ts diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 8fc89e055..f8d279f51 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -57,6 +57,7 @@ import { } from "@/lib/bootstrap"; import { displayTitle, sortSessions } from "@/lib/chat-groups"; import { deriveTitle } from "@/lib/format"; +import type { ModelSetupIntent } from "@/lib/model-setup"; import { NanobotClient } from "@/lib/nanobot-client"; import { ClientProvider, useClient } from "@/providers/ClientProvider"; import type { @@ -1053,6 +1054,8 @@ function Shell({ const [temporaryChatEnabled, setTemporaryChatEnabled] = useState(false); const [settingsInitialSection, setSettingsInitialSection] = useState(initialRouteRef.current.settingsSection); + const [modelSetupIntent, setModelSetupIntent] = useState(null); + const [chatFocusRequest, setChatFocusRequest] = useState(0); const [hostSidebarOpen, setHostSidebarOpen] = useState(readSidebarOpen); const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false); @@ -1981,8 +1984,12 @@ function Shell({ [onSelectChat], ); - const onOpenSettings = useCallback((section: SettingsSectionKey = "overview") => { + const onOpenSettings = useCallback(( + section: SettingsSectionKey = "overview", + setupIntent: ModelSetupIntent | null = null, + ) => { setSessionSearchOpen(false); + setModelSetupIntent(setupIntent); navigate({ view: "settings", activeKey, settingsSection: section }); setMobileSidebarOpen(false); }, [activeKey, navigate]); @@ -1991,8 +1998,8 @@ function Shell({ void loadSettingsView(); }, []); - const onOpenModelSettings = useCallback(() => { - onOpenSettings("models"); + const onOpenModelSettings = useCallback((intent?: ModelSetupIntent) => { + onOpenSettings("models", intent ?? null); }, [onOpenSettings]); const onOpenApps = useCallback(() => { @@ -2015,6 +2022,7 @@ function Shell({ const onSettingsSectionChange = useCallback( (section: SettingsSectionKey) => { + setModelSetupIntent(null); navigate({ view: shellViewForSettingsSection(section), activeKey, @@ -2025,7 +2033,9 @@ function Shell({ ); const onBackToChat = useCallback(() => { + const restoreComposerFocus = modelSetupIntent !== null; setMobileSidebarOpen(false); + setModelSetupIntent(null); const nextKey = (() => { if (!activeKey) return null; if (topicSessions.some((session) => session.key === activeKey)) return activeKey; @@ -2036,7 +2046,10 @@ function Shell({ activeKey: nextKey, settingsSection: "overview", }); - }, [activeKey, navigate, topicSessions]); + if (restoreComposerFocus) { + setChatFocusRequest((value) => value + 1); + } + }, [activeKey, modelSetupIntent, navigate, topicSessions]); const onRestart = useCallback(() => { const chatId = activeSession?.chatId ?? client.defaultChatId; @@ -2792,6 +2805,7 @@ function Shell({ onWorkspaceScopeChange={applyWorkspaceScope} settingsSnapshot={settingsSnapshot} onOpenModelSettings={onOpenModelSettings} + focusComposerRequest={chatFocusRequest} skills={skills} /> ); @@ -2850,6 +2864,7 @@ function Shell({ }} settingsSnapshot={settingsSnapshot} onOpenModelSettings={onOpenModelSettings} + focusComposerRequest={context.active ? chatFocusRequest : 0} skills={skills} /> ); @@ -2863,6 +2878,7 @@ function Shell({ theme={theme} initialSection={settingsInitialSection} initialSettings={settingsSnapshot} + modelSetupIntent={modelSetupIntent} showSidebar={view === "settings"} onToggleTheme={toggle} onBackToChat={onBackToChat} diff --git a/webui/src/components/settings/SettingsPage.tsx b/webui/src/components/settings/SettingsPage.tsx index ed76dcd2c..a17b1783c 100644 --- a/webui/src/components/settings/SettingsPage.tsx +++ b/webui/src/components/settings/SettingsPage.tsx @@ -31,10 +31,12 @@ import { ChannelsSettings } from "@/components/settings/system/ChannelsSettings" import { RuntimeSettings } from "@/components/settings/system/RuntimeSettings"; import type { SettingsController } from "@/components/settings/useSettingsController"; import type { SkillSummary } from "@/lib/types"; +import type { ModelSetupIntent } from "@/lib/model-setup"; import { cn } from "@/lib/utils"; interface SettingsPageProps { controller: SettingsController; + modelSetupIntent: ModelSetupIntent | null; theme: "light" | "dark"; showSidebar: boolean; onToggleTheme: () => void; @@ -47,6 +49,7 @@ interface SettingsPageProps { export function SettingsPage({ controller, + modelSetupIntent, theme, showSidebar, onToggleTheme, @@ -283,6 +286,7 @@ export function SettingsPage({ providerSaving={providerSaving} showBrandLogos={localPrefs.brandLogos} remoteBrowserAccess={remoteBrowserAccess} + setupIntent={modelSetupIntent} onToggleProvider={handleToggleProvider} onToggleProviderKey={toggleProviderKeyVisibility} onToggleProviderKeyEditing={toggleProviderKeyEditing} diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 07f162b0f..e4b2d09f8 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -1,6 +1,7 @@ import { SettingsPage } from "@/components/settings/SettingsPage"; import type { SettingsSectionKey } from "@/components/settings/contracts"; import { useSettingsController } from "@/components/settings/useSettingsController"; +import type { ModelSetupIntent } from "@/lib/model-setup"; import type { SettingsPayload, SkillSummary } from "@/lib/types"; export type { SettingsSectionKey } from "@/components/settings/contracts"; @@ -9,6 +10,7 @@ interface SettingsViewProps { theme: "light" | "dark"; initialSection?: SettingsSectionKey; initialSettings?: SettingsPayload | null; + modelSetupIntent?: ModelSetupIntent | null; showSidebar?: boolean; onToggleTheme: () => void; onBackToChat: () => void; @@ -27,6 +29,7 @@ export function SettingsView({ theme, initialSection = "overview", initialSettings = null, + modelSetupIntent = null, showSidebar = true, onToggleTheme, onBackToChat, @@ -53,6 +56,7 @@ export function SettingsView({ return ( { value: "responses", label: "Responses" }, ]; -const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map( +const LOCAL_PROVIDER_ORDER = new Map( ["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [ name, index, ]), ); +const MODEL_SETUP_TITLE_KEYS: Record = { + account: "thread.composer.modelSetup.account.title", + apiKey: "thread.composer.modelSetup.apiKey.title", + local: "thread.composer.modelSetup.local.title", +}; export function ProviderOAuthLoginDialog({ flow, @@ -655,6 +664,7 @@ export function ProvidersSettings({ providerSaving, showBrandLogos, remoteBrowserAccess, + setupIntent, onToggleProvider, onToggleProviderKey, onToggleProviderKeyEditing, @@ -678,6 +688,7 @@ export function ProvidersSettings({ providerSaving: string | null; showBrandLogos: boolean; remoteBrowserAccess: boolean; + setupIntent?: ModelSetupIntent | null; onToggleProvider: (provider: string) => void; onToggleProviderKey: (provider: string) => void; onToggleProviderKeyEditing: (provider: string) => void; @@ -697,23 +708,57 @@ export function ProvidersSettings({ const [customProviderDraft, setCustomProviderDraft] = useState( emptyCustomProviderDraft, ); + const sectionRef = useRef(null); + const [setupPickerOpen, setSetupPickerOpen] = useState(false); const configuredProviders = settings.providers.filter((provider) => provider.configured); const unconfiguredProviders = useMemo( () => - orderUnconfiguredProviders( + orderProviderPickerOptions( settings.providers.filter( (provider) => !provider.configured && provider.name !== "custom", ), ), [settings.providers], ); + const providerPickerOptions = useMemo( + () => setupIntent + ? orderProviderPickerOptions( + settings.providers.filter( + (provider) => + provider.name !== "custom" + && providerMatchesModelSetupIntent(provider, setupIntent), + ), + ) + : unconfiguredProviders, + [settings.providers, setupIntent, unconfiguredProviders], + ); const selectedUnconfiguredProvider = unconfiguredProviders.find((provider) => provider.name === expandedProvider) ?? null; const customProviderSaving = providerSaving === CUSTOM_PROVIDER_CREATION_KEY; + useEffect(() => { + if (!setupIntent) { + setSetupPickerOpen(false); + return; + } + const frame = window.requestAnimationFrame(() => { + sectionRef.current?.scrollIntoView?.({ block: "start" }); + setSetupPickerOpen(true); + }); + return () => window.cancelAnimationFrame(frame); + }, [setupIntent]); const toggleProvider = (providerName: string) => { setCreatingCustomProvider(false); onToggleProvider(providerName); }; + const chooseProvider = (providerName: string) => { + setCreatingCustomProvider(false); + if (expandedProvider !== providerName) onToggleProvider(providerName); + window.requestAnimationFrame(() => { + document.getElementById(`settings-provider-${providerName}`)?.scrollIntoView?.({ + block: "start", + }); + }); + }; const beginCustomProviderCreation = () => { if (expandedProvider) onToggleProvider(expandedProvider); setCustomProviderDraft(emptyCustomProviderDraft()); @@ -776,7 +821,11 @@ export function ProvidersSettings({ ? (nanobotFeatures?.features ?? []).find((feature) => feature.name === supportName) : null; return ( -
+
) : null} -
+
{tx("settings.providers.title", "Model providers")} @@ -1233,7 +1282,12 @@ export function ProvidersSettings({ : null} {customProviderForm} {!expandedProvider && !creatingCustomProvider ? ( - +