fix(webui): route first-run model setup choices

This commit is contained in:
Xubin Ren
2026-09-02 01:02:26 +08:00
parent d0a8a33bc9
commit c52e26f788
12 changed files with 329 additions and 73 deletions
+20 -4
View File
@@ -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<SettingsSectionKey>(initialRouteRef.current.settingsSection);
const [modelSetupIntent, setModelSetupIntent] = useState<ModelSetupIntent | null>(null);
const [chatFocusRequest, setChatFocusRequest] = useState(0);
const [hostSidebarOpen, setHostSidebarOpen] =
useState<boolean>(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}
@@ -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}
@@ -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 (
<SettingsPage
controller={controller}
modelSetupIntent={modelSetupIntent}
theme={theme}
showSidebar={showSidebar}
onToggleTheme={onToggleTheme}
@@ -1,4 +1,4 @@
import { useMemo, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
ChevronDown,
Clipboard,
@@ -42,6 +42,10 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { providerBrand } from "@/lib/provider-brand";
import {
providerMatchesModelSetupIntent,
type ModelSetupIntent,
} from "@/lib/model-setup";
import { cn } from "@/lib/utils";
import type {
NanobotFeaturesPayload,
@@ -232,12 +236,17 @@ const OPENAI_API_TYPE_OPTIONS: Array<{ value: ProviderApiType; label: string }>
{ 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<ModelSetupIntent, string> = {
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<CustomProviderDraft>(
emptyCustomProviderDraft,
);
const sectionRef = useRef<HTMLElement>(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 (
<div key={provider.name} className="divide-y divide-border/45">
<div
key={provider.name}
id={`settings-provider-${provider.name}`}
className="divide-y divide-border/45"
>
<button
type="button"
aria-expanded={expanded}
@@ -1222,7 +1271,7 @@ export function ProvidersSettings({
</div>
</div>
) : null}
<section>
<section ref={sectionRef}>
<SettingsSectionTitle>
{tx("settings.providers.title", "Model providers")}
</SettingsSectionTitle>
@@ -1233,7 +1282,12 @@ export function ProvidersSettings({
: null}
{customProviderForm}
{!expandedProvider && !creatingCustomProvider ? (
<DropdownMenu modal={false}>
<DropdownMenu
modal={false}
{...(setupIntent
? { open: setupPickerOpen, onOpenChange: setSetupPickerOpen }
: {})}
>
<DropdownMenuTrigger asChild>
<button
type="button"
@@ -1244,10 +1298,12 @@ export function ProvidersSettings({
<Plus className="h-5 w-5" aria-hidden />
</span>
<span className="truncate text-[15px] font-semibold text-foreground">
{tx(
"settings.providers.addOwnProvider",
"Add your own model provider",
)}
{setupIntent
? t(MODEL_SETUP_TITLE_KEYS[setupIntent])
: tx(
"settings.providers.addOwnProvider",
"Add your own model provider",
)}
</span>
</span>
<ChevronDown
@@ -1261,25 +1317,25 @@ export function ProvidersSettings({
sideOffset={8}
className="max-h-[24rem] w-[380px] max-w-[calc(100vw-2rem)] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
>
<DropdownMenuItem
onSelect={beginCustomProviderCreation}
className="flex min-h-[54px] cursor-default items-center gap-3 px-2.5 py-2 focus:bg-muted/85 focus:text-foreground"
>
<ProviderIcon provider="custom" showBrandLogos={showBrandLogos} />
<span className="truncate text-[13px] font-medium">
{tx("settings.providers.customProvider", "Custom provider")}
</span>
</DropdownMenuItem>
{unconfiguredProviders.length > 0 ? <DropdownMenuSeparator /> : null}
{unconfiguredProviders.map((provider) => (
{!setupIntent || setupIntent === "apiKey" ? (
<DropdownMenuItem
onSelect={beginCustomProviderCreation}
className="flex min-h-[54px] cursor-default items-center gap-3 px-2.5 py-2 focus:bg-muted/85 focus:text-foreground"
>
<ProviderIcon provider="custom" showBrandLogos={showBrandLogos} />
<span className="truncate text-[13px] font-medium">
{tx("settings.providers.customProvider", "Custom provider")}
</span>
</DropdownMenuItem>
) : null}
{providerPickerOptions.length > 0
&& (!setupIntent || setupIntent === "apiKey")
? <DropdownMenuSeparator />
: null}
{providerPickerOptions.map((provider) => (
<DropdownMenuItem
key={provider.name}
onSelect={() => {
setCreatingCustomProvider(false);
if (expandedProvider !== provider.name) {
onToggleProvider(provider.name);
}
}}
onSelect={() => chooseProvider(provider.name)}
className="flex min-h-[54px] cursor-default items-center gap-3 px-2.5 py-2 focus:bg-muted/85 focus:text-foreground"
>
<ProviderIcon
@@ -1300,7 +1356,7 @@ export function ProvidersSettings({
);
}
function orderUnconfiguredProviders(
function orderProviderPickerOptions(
providers: SettingsPayload["providers"],
): SettingsPayload["providers"] {
return providers
@@ -1313,7 +1369,7 @@ function orderUnconfiguredProviders(
}
function providerVisibilityRank(provider: SettingsPayload["providers"][number]): number {
const localRank = LOCAL_UNCONFIGURED_PROVIDER_ORDER.get(provider.name);
const localRank = LOCAL_PROVIDER_ORDER.get(provider.name);
if (localRank !== undefined) return localRank;
if ((provider.api_key_required ?? true) === false) return 100;
return 200;
@@ -1,3 +1,4 @@
import { useRef } from "react";
import { Check, Cloud, KeyRound, Laptop } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -9,14 +10,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
export interface ModelSetupAvailability {
account: boolean;
apiKey: boolean;
local: boolean;
}
export type ModelSetupIntent = keyof ModelSetupAvailability;
import type { ModelSetupAvailability, ModelSetupIntent } from "@/lib/model-setup";
const SETUP_OPTIONS = [
{
@@ -59,14 +53,22 @@ export function ModelSetupDialog({
onSelect: (intent: ModelSetupIntent) => void;
}) {
const { t } = useTranslation();
const selectedRef = useRef(false);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) selectedRef.current = false;
onOpenChange(nextOpen);
}}
>
<DialogContent
className="max-w-md gap-5 p-5 sm:p-6"
onCloseAutoFocus={(event) => {
event.preventDefault();
onReturnFocus();
if (!selectedRef.current) onReturnFocus();
selectedRef.current = false;
}}
>
<DialogHeader className="pr-7">
@@ -89,7 +91,10 @@ export function ModelSetupDialog({
key={option.intent}
type="button"
aria-label={t(option.titleKey, { defaultValue: option.title })}
onClick={() => onSelect(option.intent)}
onClick={() => {
selectedRef.current = true;
onSelect(option.intent);
}}
className={cn(
"group flex min-h-[68px] w-full items-center gap-3 rounded-control border border-border/55 bg-background px-3.5 py-3 text-left",
"transition-[background-color,border-color,transform] duration-150 ease-out hover:border-border hover:bg-muted/45 active:scale-[0.99]",
@@ -70,10 +70,7 @@ import {
ModelPresetBadge,
type ModelPresetOption,
} from "@/components/thread/ModelPresetBadge";
import {
ModelSetupDialog,
type ModelSetupAvailability,
} from "@/components/thread/ModelSetupDialog";
import { ModelSetupDialog } from "@/components/thread/ModelSetupDialog";
import {
ACCEPT_ATTR,
MAX_ATTACHMENTS_PER_MESSAGE,
@@ -117,6 +114,7 @@ import {
} from "@/lib/session-drag";
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
import { formatCompactTokenCount } from "@/lib/format";
import type { ModelSetupAvailability, ModelSetupIntent } from "@/lib/model-setup";
import { cn } from "@/lib/utils";
const VOICE_SHORTCUT_CODE = "KeyD";
@@ -304,7 +302,7 @@ interface ThreadComposerProps {
modelNeedsSetup?: boolean;
modelSetupAvailability?: ModelSetupAvailability;
fallbackModelName?: string | null;
onModelBadgeClick?: () => void;
onModelBadgeClick?: (intent?: ModelSetupIntent) => void;
onManageModels?: () => void;
contextUsage?: ComposerContextUsage | null;
variant?: "thread" | "hero";
@@ -2137,9 +2135,9 @@ export function ThreadComposer({
setModelSetupOpen(true);
}, []);
const continueModelSetup = useCallback(() => {
const continueModelSetup = useCallback((intent: ModelSetupIntent) => {
setModelSetupOpen(false);
onModelBadgeClick?.();
onModelBadgeClick?.(intent);
}, [onModelBadgeClick]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
+13 -20
View File
@@ -14,7 +14,6 @@ import {
type ComposerContextUsage,
} from "@/components/thread/ThreadComposer";
import type { ModelPresetOption } from "@/components/thread/ModelPresetBadge";
import type { ModelSetupAvailability } from "@/components/thread/ModelSetupDialog";
import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport";
@@ -40,6 +39,10 @@ import {
} from "@/lib/mcp-preset-events";
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import {
modelSetupAvailability,
type ModelSetupIntent,
} from "@/lib/model-setup";
import type {
ChatSummary,
SettingsPayload,
@@ -355,6 +358,7 @@ interface ThreadShellProps {
composerPortalTarget?: HTMLElement | null;
composerActive?: boolean;
composerInputAriaLabel?: string;
focusComposerRequest?: number;
emptyComposerVariant?: "hero" | "thread";
workspaceScope?: WorkspaceScopePayload | null;
workspaceDefaultScope?: WorkspaceScopePayload | null;
@@ -363,7 +367,7 @@ interface ThreadShellProps {
workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
settingsSnapshot?: SettingsPayload | null;
onOpenModelSettings?: () => void;
onOpenModelSettings?: (intent?: ModelSetupIntent) => void;
skills?: SkillSummary[];
}
@@ -383,22 +387,6 @@ interface ModelBadgeInfo {
needsSetup: boolean;
}
const LOCAL_MODEL_PROVIDERS = new Set(["atomic_chat", "lm_studio", "ollama", "vllm"]);
function modelSetupAvailability(settings: SettingsPayload | null): ModelSetupAvailability {
const configured = settings?.providers.filter((provider) => provider.configured) ?? [];
const isLocal = (provider: SettingsPayload["providers"][number]) => {
if (LOCAL_MODEL_PROVIDERS.has(provider.name)) return true;
const apiBase = provider.api_base?.trim().toLowerCase() ?? "";
return apiBase.includes("localhost") || apiBase.includes("127.0.0.1") || apiBase.includes("[::1]");
};
return {
account: configured.some((provider) => provider.auth_type === "oauth"),
apiKey: configured.some((provider) => provider.auth_type !== "oauth" && !isLocal(provider)),
local: configured.some(isLocal),
};
}
function modelPresetForBadge(
settings: SettingsPayload | null,
scopedPreset: string | null,
@@ -671,6 +659,7 @@ export function ThreadShell({
composerPortalTarget,
composerActive = true,
composerInputAriaLabel,
focusComposerRequest = 0,
emptyComposerVariant = "hero",
workspaceScope = null,
workspaceDefaultScope = null,
@@ -980,7 +969,10 @@ export function ThreadShell({
const modelBadgeLabel = modelBadge.needsSetup
? t("thread.composer.chooseAI", { defaultValue: "Choose your AI" })
: modelBadge.label;
const setupAvailability = useMemo(() => modelSetupAvailability(settings), [settings]);
const setupAvailability = useMemo(
() => modelSetupAvailability(settings?.providers),
[settings?.providers],
);
useEffect(() => {
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
setHeroGreetingKey(randomHeroGreetingKey());
@@ -1563,7 +1555,7 @@ export function ThreadShell({
transcriptionProvider={settingsSnapshot?.transcription?.provider}
ingressLimits={ingressLimits}
quotedContext={quotedContext}
focusRequest={composerFocusSignal}
focusRequest={composerFocusSignal + focusComposerRequest}
onQuotedContextChange={setQuotedContext}
/>
) : (
@@ -1611,6 +1603,7 @@ export function ThreadShell({
onWorkspaceScopeChange={onWorkspaceScopeChange}
transcriptionProvider={settingsSnapshot?.transcription?.provider}
ingressLimits={ingressLimits}
focusRequest={composerFocusSignal + focusComposerRequest}
/>
)}
</>
+48
View File
@@ -0,0 +1,48 @@
import type { SettingsPayload } from "@/lib/types";
export type ModelSetupIntent = "account" | "apiKey" | "local";
export type ModelSetupAvailability = Record<ModelSetupIntent, boolean>;
type Provider = SettingsPayload["providers"][number];
const LOCAL_MODEL_PROVIDERS = new Set([
"atomic_chat",
"lm_studio",
"ollama",
"ovms",
"vllm",
]);
function isLocalModelProvider(provider: Provider): boolean {
if (LOCAL_MODEL_PROVIDERS.has(provider.name)) return true;
const apiBase = provider.api_base?.trim().toLowerCase() ?? "";
return (
apiBase.includes("localhost")
|| apiBase.includes("127.0.0.1")
|| apiBase.includes("[::1]")
);
}
export function modelSetupIntentForProvider(provider: Provider): ModelSetupIntent {
if (provider.auth_type === "oauth") return "account";
return isLocalModelProvider(provider) ? "local" : "apiKey";
}
export function providerMatchesModelSetupIntent(
provider: Provider,
intent: ModelSetupIntent,
): boolean {
return modelSetupIntentForProvider(provider) === intent;
}
export function modelSetupAvailability(
providers: SettingsPayload["providers"] | null | undefined,
): ModelSetupAvailability {
const configured = providers?.filter((provider) => provider.configured) ?? [];
return {
account: configured.some((provider) => modelSetupIntentForProvider(provider) === "account"),
apiKey: configured.some((provider) => modelSetupIntentForProvider(provider) === "apiKey"),
local: configured.some((provider) => modelSetupIntentForProvider(provider) === "local"),
};
}
+61
View File
@@ -481,6 +481,67 @@ describe("App layout", () => {
expect(screen.getByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument();
});
it("carries a first-run setup path into settings and restores composer focus", async () => {
const user = userEvent.setup();
const base = baseSettingsPayload();
mockFetchRoutes({
"/api/settings": {
...base,
agent: {
...base.agent,
model: "",
resolved_provider: "",
has_api_key: false,
model_preset: "",
},
model_presets: [],
model_call_order: [],
providers: [
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
},
{
name: "deepseek",
label: "DeepSeek",
configured: false,
auth_type: "api_key",
},
{
name: "ollama",
label: "Ollama",
configured: false,
api_base: "http://127.0.0.1:11434",
},
],
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
await user.click(await screen.findByRole("button", { name: "Choose your AI" }));
await user.click(await screen.findByRole("button", { name: "Run locally" }));
expect(
await screen.findByRole("navigation", { name: "Settings sections" }),
).toBeInTheDocument();
const providerMenu = await screen.findByRole("menu");
expect(within(providerMenu).getByRole("menuitem", { name: "Ollama" }))
.toBeInTheDocument();
expect(within(providerMenu).queryByRole("menuitem", { name: "OpenAI Codex" }))
.not.toBeInTheDocument();
expect(within(providerMenu).queryByRole("menuitem", { name: "DeepSeek" }))
.not.toBeInTheDocument();
const composer = screen.getByRole("textbox", { name: "Message input" });
expect(composer).not.toHaveFocus();
await user.click(screen.getByRole("button", { name: "Back to chat" }));
await waitFor(() => expect(composer).toHaveFocus());
});
it("places Automations after Skills in the main sidebar", async () => {
render(<App />);
@@ -14,6 +14,48 @@ async function chooseProviderToConfigure(label: string) {
describe("Settings providers", () => {
installSettingsViewTestHooks();
it.each([
["account", "OpenAI Codex", ["DeepSeek", "Ollama", "Custom provider"]],
["apiKey", "DeepSeek", ["OpenAI Codex", "Ollama"]],
["local", "Ollama", ["OpenAI Codex", "DeepSeek", "Custom provider"]],
] as const)("opens the %s first-run provider path", async (intent, expected, excluded) => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
providers: [
{
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth",
},
{
name: "deepseek",
label: "DeepSeek",
configured: false,
auth_type: "api_key",
},
{
name: "ollama",
label: "Ollama",
configured: false,
api_base: "http://127.0.0.1:11434",
},
],
};
renderSettingsView({
initialSection: "models",
initialSettings: payload,
modelSetupIntent: intent,
});
const menu = await screen.findByRole("menu");
expect(within(menu).getByRole("menuitem", { name: expected })).toBeInTheDocument();
for (const label of excluded) {
expect(within(menu).queryByRole("menuitem", { name: label })).not.toBeInTheDocument();
}
});
it("signs in to the xAI Grok provider", async () => {
const base = settingsPayload();
+3
View File
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, vi } from "vitest";
import { SettingsView } from "@/components/settings/SettingsView";
import { ClientProvider } from "@/providers/ClientProvider";
import type { ModelSetupIntent } from "@/lib/model-setup";
import type { SettingsPayload } from "@/lib/types";
export const requestMutationMock = vi.fn();
@@ -137,6 +138,7 @@ export function renderSettingsView(
| "browser"
| "runtime";
initialSettings?: SettingsPayload;
modelSetupIntent?: ModelSetupIntent;
showSidebar?: boolean;
onBackToChat?: () => void;
onSettingsChange?: (payload: SettingsPayload) => void;
@@ -149,6 +151,7 @@ export function renderSettingsView(
theme="light"
initialSection={options.initialSection ?? "apps"}
initialSettings={options.initialSettings}
modelSetupIntent={options.modelSetupIntent}
showSidebar={options.showSidebar}
onToggleTheme={() => {}}
onBackToChat={options.onBackToChat ?? (() => {})}
+28 -2
View File
@@ -887,6 +887,7 @@ describe("ThreadShell", () => {
fireEvent.click(screen.getByRole("button", { name: "Close" }));
const input = screen.getByRole("textbox", { name: "Message input" });
await waitFor(() => expect(input).toHaveFocus());
fireEvent.change(input, {
target: { value: "hello" },
});
@@ -896,12 +897,37 @@ describe("ThreadShell", () => {
expect(input).toHaveValue("hello");
fireEvent.click(screen.getByRole("button", { name: "Use an API key" }));
expect(onOpenModelSettings).toHaveBeenCalledTimes(1);
expect(onOpenModelSettings).toHaveBeenCalledWith("apiKey");
expect(input).toHaveValue("hello");
await waitFor(() => expect(input).toHaveFocus());
expect(input).not.toHaveFocus();
expect(client.sendMessage).not.toHaveBeenCalled();
});
it("focuses the composer when returning from model setup", async () => {
const client = makeClient();
const settings = modelSettings("openai-codex/gpt-5.1-codex", "openai_codex");
settings.agent.has_api_key = false;
const view = (focusComposerRequest: number) => wrap(
client,
<ThreadShell
session={session("setup-focus")}
title="Setup focus"
onToggleSidebar={() => {}}
settingsSnapshot={settings}
focusComposerRequest={focusComposerRequest}
/>,
"openai-codex/gpt-5.1-codex",
);
const { rerender } = render(view(0));
const input = await screen.findByRole("textbox", { name: "Message input" });
input.blur();
expect(input).not.toHaveFocus();
rerender(view(1));
await waitFor(() => expect(input).toHaveFocus());
});
it("keeps image generation controls out of the composer", async () => {
const client = makeClient();
const disabledSettings = modelSettings("deepseek-v4-pro", "deepseek");