From d0a8a33bc9f8342b0b1d067e459c55819d328ada Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:18:58 +0800 Subject: [PATCH] feat(webui): add first-run AI setup flow --- .../components/thread/ModelPresetBadge.tsx | 7 +- .../components/thread/ModelSetupDialog.tsx | 123 ++++++++++++++++++ .../src/components/thread/ThreadComposer.tsx | 32 ++++- webui/src/components/thread/ThreadShell.tsx | 22 +++- webui/src/i18n/locales/en/common.json | 21 ++- webui/src/i18n/locales/es/common.json | 21 ++- webui/src/i18n/locales/fr/common.json | 21 ++- webui/src/i18n/locales/id/common.json | 21 ++- webui/src/i18n/locales/ja/common.json | 21 ++- webui/src/i18n/locales/ko/common.json | 21 ++- webui/src/i18n/locales/pt-BR/common.json | 21 ++- webui/src/i18n/locales/vi/common.json | 21 ++- webui/src/i18n/locales/zh-CN/common.json | 21 ++- webui/src/i18n/locales/zh-TW/common.json | 21 ++- webui/src/tests/thread-shell.test.tsx | 46 +++++-- 15 files changed, 401 insertions(+), 39 deletions(-) create mode 100644 webui/src/components/thread/ModelSetupDialog.tsx diff --git a/webui/src/components/thread/ModelPresetBadge.tsx b/webui/src/components/thread/ModelPresetBadge.tsx index 6bebf6579..143fff734 100644 --- a/webui/src/components/thread/ModelPresetBadge.tsx +++ b/webui/src/components/thread/ModelPresetBadge.tsx @@ -6,7 +6,7 @@ import { type KeyboardEvent, type PointerEvent, } from "react"; -import { Check, CircleHelp, SlidersHorizontal, Sparkles } from "lucide-react"; +import { Check, SlidersHorizontal, Sparkles } from "lucide-react"; import { useTranslation } from "react-i18next"; import { @@ -539,7 +539,6 @@ function PresetPill({ "composer-model-badge composer-model-pill inline-flex h-full max-w-full min-w-0 shrink-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/70", "w-fit", "transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible:ring-2 group-focus-visible:ring-ring/45", - needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200", isHero ? "gap-1.5 px-2.5 text-[12px]" : "gap-2 px-3 text-[12.5px]", offset !== undefined && "composer-model-pill-dock", )} @@ -595,13 +594,13 @@ function PresetProviderIcon({ data-testid={testId} className={cn( "grid shrink-0 place-items-center", - needsSetup && "text-amber-800 dark:text-amber-200", + needsSetup && "text-muted-foreground", isHero ? "h-4 w-4" : "h-[18px] w-[18px]", )} aria-hidden > {needsSetup ? ( - + ) : logoUrl ? ( void; + onReturnFocus: () => void; + onSelect: (intent: ModelSetupIntent) => void; +}) { + const { t } = useTranslation(); + + return ( + + { + event.preventDefault(); + onReturnFocus(); + }} + > + + + {t("thread.composer.modelSetup.title", { defaultValue: "Choose your AI" })} + + + {t("thread.composer.modelSetup.description", { + defaultValue: "Pick a starting point. You can change models at any time.", + })} + + + +
+ {SETUP_OPTIONS.map((option) => { + const Icon = option.icon; + const ready = availability[option.intent]; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 3bd2b9ebd..c2a0e431c 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -70,6 +70,10 @@ import { ModelPresetBadge, type ModelPresetOption, } from "@/components/thread/ModelPresetBadge"; +import { + ModelSetupDialog, + type ModelSetupAvailability, +} from "@/components/thread/ModelSetupDialog"; import { ACCEPT_ATTR, MAX_ATTACHMENTS_PER_MESSAGE, @@ -298,6 +302,7 @@ interface ThreadComposerProps { modelProvider?: string | null; modelProviderLabel?: string | null; modelNeedsSetup?: boolean; + modelSetupAvailability?: ModelSetupAvailability; fallbackModelName?: string | null; onModelBadgeClick?: () => void; onManageModels?: () => void; @@ -997,6 +1002,7 @@ export function ThreadComposer({ modelProvider = null, modelProviderLabel = null, modelNeedsSetup = false, + modelSetupAvailability = { account: false, apiKey: false, local: false }, fallbackModelName = null, onModelBadgeClick, onManageModels, @@ -1036,6 +1042,7 @@ export function ThreadComposer({ } | null>(null); const [inlineError, setInlineError] = useState(null); const [sendPending, setSendPending] = useState(false); + const [modelSetupOpen, setModelSetupOpen] = useState(false); const interactionDisabled = !!disabled || sendPending; const [voiceErrorFading, setVoiceErrorFading] = useState(false); const [slashMenuDismissed, setSlashMenuDismissed] = useState(false); @@ -2008,7 +2015,7 @@ export function ThreadComposer({ const submit = useCallback(() => { if (modelNeedsSetup) { - onModelBadgeClick?.(); + setModelSetupOpen(true); return; } if (!canSend) return; @@ -2116,7 +2123,6 @@ export function ThreadComposer({ isStreaming, maxTextBytes, modelNeedsSetup, - onModelBadgeClick, onSend, onStop, onQuotedContextChange, @@ -2127,6 +2133,15 @@ export function ThreadComposer({ value, ]); + const openModelSetup = useCallback(() => { + setModelSetupOpen(true); + }, []); + + const continueModelSetup = useCallback(() => { + setModelSetupOpen(false); + onModelBadgeClick?.(); + }, [onModelBadgeClick]); + const onKeyDown = (e: ReactKeyboardEvent) => { if (showCliAppMenu) { if (e.key === "ArrowDown") { @@ -2548,7 +2563,7 @@ export function ThreadComposer({ needsSetup={modelNeedsSetup} fallbackModelName={fallbackModelName} isHero={isHero} - onClick={modelNeedsSetup ? onModelBadgeClick : undefined} + onClick={modelNeedsSetup ? openModelSetup : undefined} /> ) : null} {!voiceRecorder.isRecording ? : null} @@ -2607,10 +2622,10 @@ export function ThreadComposer({ showStopButton ? t("thread.composer.stop") : modelNeedsSetup - ? t("thread.composer.configureModel", { defaultValue: "Configure model" }) + ? t("thread.composer.openModelSetup", { defaultValue: "Open AI setup" }) : t("thread.composer.send") } - onClick={showStopButton ? handleStop : modelNeedsSetup ? onModelBadgeClick : undefined} + onClick={showStopButton ? handleStop : modelNeedsSetup ? openModelSetup : undefined} className={cn( "thread-composer-action touch-target rounded-full transition-transform", showStopButton @@ -2656,6 +2671,13 @@ export function ThreadComposer({ ) : null} + textareaRef.current?.focus()} + onSelect={continueModelSetup} + /> ); } diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index f07781bff..33eafa869 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -14,6 +14,7 @@ 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"; @@ -382,6 +383,22 @@ 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, @@ -961,8 +978,9 @@ export function ThreadShell({ [activeModelPreset, modelName, settings], ); const modelBadgeLabel = modelBadge.needsSetup - ? t("thread.composer.modelNotConfigured", { defaultValue: "Model not configured" }) + ? t("thread.composer.chooseAI", { defaultValue: "Choose your AI" }) : modelBadge.label; + const setupAvailability = useMemo(() => modelSetupAvailability(settings), [settings]); useEffect(() => { if (showHeroComposer && !wasShowingHeroComposerRef.current) { setHeroGreetingKey(randomHeroGreetingKey()); @@ -1517,6 +1535,7 @@ export function ThreadShell({ modelProvider={modelBadge.provider} modelProviderLabel={modelBadge.providerLabel} modelNeedsSetup={modelBadge.needsSetup} + modelSetupAvailability={setupAvailability} fallbackModelName={fallbackModelName} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} onManageModels={onOpenModelSettings} @@ -1566,6 +1585,7 @@ export function ThreadShell({ modelProvider={modelBadge.provider} modelProviderLabel={modelBadge.providerLabel} modelNeedsSetup={modelBadge.needsSetup} + modelSetupAvailability={setupAvailability} fallbackModelName={fallbackModelName} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} onManageModels={onOpenModelSettings} diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 403b1643c..bb5a1f2b2 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -1193,8 +1193,25 @@ "stop": "Stop response", "quotedContext": "Quoted context", "removeQuotedContext": "Remove quoted context", - "modelNotConfigured": "Model not configured", - "configureModel": "Configure model", + "openModelSetup": "Open AI setup", + "chooseAI": "Choose your AI", + "modelSetup": { + "title": "Choose your AI", + "description": "Pick a starting point. You can change models at any time.", + "ready": "Ready", + "account": { + "title": "Connect an account", + "description": "Use a supported AI subscription." + }, + "apiKey": { + "title": "Use an API key", + "description": "Bring a key from your preferred provider." + }, + "local": { + "title": "Run locally", + "description": "Connect Ollama, LM Studio, or vLLM." + } + }, "switchModel": "Switch model for this chat", "manageModels": "Manage models", "context": { diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 9e632c360..19955fcd0 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -1180,8 +1180,25 @@ "stop": "Detener respuesta", "quotedContext": "Contexto citado", "removeQuotedContext": "Quitar contexto citado", - "modelNotConfigured": "Modelo no configurado", - "configureModel": "Configurar modelo", + "openModelSetup": "Abrir configuración de IA", + "chooseAI": "Elige tu IA", + "modelSetup": { + "title": "Elige tu IA", + "description": "Elige cómo empezar. Puedes cambiar de modelo en cualquier momento.", + "ready": "Listo", + "account": { + "title": "Conectar una cuenta", + "description": "Usa una suscripción de IA compatible." + }, + "apiKey": { + "title": "Usar una clave API", + "description": "Usa una clave de tu proveedor preferido." + }, + "local": { + "title": "Ejecutar localmente", + "description": "Conecta Ollama, LM Studio o vLLM." + } + }, "switchModel": "Cambiar el modelo de este chat", "manageModels": "Gestionar modelos", "context": { diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 80fcb16cf..7266d7f4e 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -1179,8 +1179,25 @@ "stop": "Arrêter la réponse", "quotedContext": "Contexte cité", "removeQuotedContext": "Supprimer le contexte cité", - "modelNotConfigured": "Modèle non configuré", - "configureModel": "Configurer le modèle", + "openModelSetup": "Ouvrir la configuration de l’IA", + "chooseAI": "Choisissez votre IA", + "modelSetup": { + "title": "Choisissez votre IA", + "description": "Choisissez un point de départ. Vous pourrez changer de modèle à tout moment.", + "ready": "Prêt", + "account": { + "title": "Connecter un compte", + "description": "Utilisez un abonnement IA compatible." + }, + "apiKey": { + "title": "Utiliser une clé API", + "description": "Utilisez la clé du fournisseur de votre choix." + }, + "local": { + "title": "Exécuter localement", + "description": "Connectez Ollama, LM Studio ou vLLM." + } + }, "switchModel": "Changer le modèle de cette conversation", "manageModels": "Gérer les modèles", "context": { diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 4d3636a45..fd0287f33 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -1179,8 +1179,25 @@ "stop": "Hentikan respons", "quotedContext": "Konteks kutipan", "removeQuotedContext": "Hapus konteks kutipan", - "modelNotConfigured": "Model belum dikonfigurasi", - "configureModel": "Konfigurasi model", + "openModelSetup": "Buka penyiapan AI", + "chooseAI": "Pilih AI Anda", + "modelSetup": { + "title": "Pilih AI Anda", + "description": "Pilih cara memulai. Anda dapat mengganti model kapan saja.", + "ready": "Siap", + "account": { + "title": "Hubungkan akun", + "description": "Gunakan langganan AI yang didukung." + }, + "apiKey": { + "title": "Gunakan kunci API", + "description": "Gunakan kunci dari penyedia pilihan Anda." + }, + "local": { + "title": "Jalankan secara lokal", + "description": "Hubungkan Ollama, LM Studio, atau vLLM." + } + }, "switchModel": "Ganti model untuk percakapan ini", "manageModels": "Kelola model", "context": { diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index aa2e8c10f..639825c1d 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -1179,8 +1179,25 @@ "stop": "応答を停止", "quotedContext": "引用したコンテキスト", "removeQuotedContext": "引用したコンテキストを削除", - "modelNotConfigured": "モデルが未設定です", - "configureModel": "モデルを設定", + "openModelSetup": "AI 設定を開く", + "chooseAI": "AI を選択", + "modelSetup": { + "title": "AI を選択", + "description": "開始方法を選んでください。モデルはいつでも変更できます。", + "ready": "準備完了", + "account": { + "title": "アカウントを接続", + "description": "対応する AI サブスクリプションを使用します。" + }, + "apiKey": { + "title": "API キーを使用", + "description": "お好みのプロバイダーのキーを使用します。" + }, + "local": { + "title": "ローカルで実行", + "description": "Ollama、LM Studio、vLLM に接続します。" + } + }, "switchModel": "この会話で使うモデルを切り替える", "manageModels": "モデルを管理", "context": { diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index a68e30c3e..657af8824 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -1179,8 +1179,25 @@ "stop": "응답 중지", "quotedContext": "인용한 문맥", "removeQuotedContext": "인용한 문맥 제거", - "modelNotConfigured": "모델이 설정되지 않음", - "configureModel": "모델 설정", + "openModelSetup": "AI 설정 열기", + "chooseAI": "AI 선택", + "modelSetup": { + "title": "AI 선택", + "description": "시작 방법을 선택하세요. 모델은 언제든 변경할 수 있습니다.", + "ready": "준비됨", + "account": { + "title": "계정 연결", + "description": "지원되는 AI 구독을 사용합니다." + }, + "apiKey": { + "title": "API 키 사용", + "description": "선호하는 제공업체의 키를 사용합니다." + }, + "local": { + "title": "로컬에서 실행", + "description": "Ollama, LM Studio 또는 vLLM에 연결합니다." + } + }, "switchModel": "이 대화에서 사용할 모델 전환", "manageModels": "모델 관리", "context": { diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index a0199f9b8..9b43cf55f 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -1193,8 +1193,25 @@ "stop": "Parar resposta", "quotedContext": "Contexto citado", "removeQuotedContext": "Remover contexto citado", - "modelNotConfigured": "Modelo não configurado", - "configureModel": "Configurar modelo", + "openModelSetup": "Abrir configuração de IA", + "chooseAI": "Escolha sua IA", + "modelSetup": { + "title": "Escolha sua IA", + "description": "Escolha como começar. Você pode trocar de modelo a qualquer momento.", + "ready": "Pronto", + "account": { + "title": "Conectar uma conta", + "description": "Use uma assinatura de IA compatível." + }, + "apiKey": { + "title": "Usar uma chave de API", + "description": "Use uma chave do seu provedor preferido." + }, + "local": { + "title": "Executar localmente", + "description": "Conecte o Ollama, LM Studio ou vLLM." + } + }, "switchModel": "Alternar o modelo desta conversa", "manageModels": "Gerenciar modelos", "context": { diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index de59fcc0c..6189922a7 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -1179,8 +1179,25 @@ "stop": "Dừng phản hồi", "quotedContext": "Ngữ cảnh được trích dẫn", "removeQuotedContext": "Xóa ngữ cảnh được trích dẫn", - "modelNotConfigured": "Chưa cấu hình mô hình", - "configureModel": "Cấu hình mô hình", + "openModelSetup": "Mở thiết lập AI", + "chooseAI": "Chọn AI của bạn", + "modelSetup": { + "title": "Chọn AI của bạn", + "description": "Chọn cách bắt đầu. Bạn có thể đổi mô hình bất cứ lúc nào.", + "ready": "Sẵn sàng", + "account": { + "title": "Kết nối tài khoản", + "description": "Dùng gói đăng ký AI được hỗ trợ." + }, + "apiKey": { + "title": "Dùng khóa API", + "description": "Dùng khóa từ nhà cung cấp bạn chọn." + }, + "local": { + "title": "Chạy cục bộ", + "description": "Kết nối Ollama, LM Studio hoặc vLLM." + } + }, "switchModel": "Chuyển mô hình cho cuộc trò chuyện này", "manageModels": "Quản lý mô hình", "context": { diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index f6fca3246..b44c0c0c0 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -1192,8 +1192,25 @@ "stop": "停止响应", "quotedContext": "引用内容", "removeQuotedContext": "移除引用内容", - "modelNotConfigured": "模型未配置", - "configureModel": "配置模型", + "openModelSetup": "打开 AI 设置", + "chooseAI": "选择你的 AI", + "modelSetup": { + "title": "选择你的 AI", + "description": "选择一种开始方式,之后可随时更换模型。", + "ready": "已就绪", + "account": { + "title": "连接账户", + "description": "使用支持的 AI 订阅。" + }, + "apiKey": { + "title": "使用 API 密钥", + "description": "使用你偏好的服务商密钥。" + }, + "local": { + "title": "在本地运行", + "description": "连接 Ollama、LM Studio 或 vLLM。" + } + }, "switchModel": "切换本次对话所用模型", "manageModels": "管理模型预设", "context": { diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 50a037739..3cf9001d4 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -1179,8 +1179,25 @@ "stop": "停止回覆", "quotedContext": "引用內容", "removeQuotedContext": "移除引用內容", - "modelNotConfigured": "尚未設定模型", - "configureModel": "設定模型", + "openModelSetup": "開啟 AI 設定", + "chooseAI": "選擇你的 AI", + "modelSetup": { + "title": "選擇你的 AI", + "description": "選擇一種開始方式,之後可隨時更換模型。", + "ready": "已就緒", + "account": { + "title": "連結帳戶", + "description": "使用支援的 AI 訂閱。" + }, + "apiKey": { + "title": "使用 API 金鑰", + "description": "使用你偏好的服務商金鑰。" + }, + "local": { + "title": "在本機執行", + "description": "連結 Ollama、LM Studio 或 vLLM。" + } + }, "switchModel": "切換此對話使用的模型", "manageModels": "管理模型預設", "context": { diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 26bd88d94..8092d1621 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -681,7 +681,7 @@ describe("ThreadShell", () => { ); expect(await screen.findByTitle("fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Choose your AI" })).not.toBeInTheDocument(); }); it("switches through every named preset while preserving call-order priority", async () => { @@ -763,7 +763,7 @@ describe("ThreadShell", () => { ); expect(await screen.findByTitle("fast · gpt-4 · Company Proxy")).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Choose your AI" })).not.toBeInTheDocument(); }); it("shows the effective fallback model in the composer badge", async () => { @@ -835,7 +835,7 @@ describe("ThreadShell", () => { expect(screen.getByText("Default")).toBeInTheDocument(); }); - it("opens model settings from the unconfigured model badge", async () => { + it("opens first-run model setup without clearing the draft", async () => { const client = makeClient(); const settings = modelSettings("openai-codex/gpt-5.1-codex", "openai_codex"); settings.agent.has_api_key = false; @@ -844,6 +844,20 @@ describe("ThreadShell", () => { ? { ...provider, auth_type: "oauth", configured: false } : provider, ); + settings.providers.push( + { + name: "xai_grok", + label: "xAI Grok", + auth_type: "oauth", + configured: true, + }, + { + name: "ollama", + label: "Ollama", + configured: true, + api_base: "http://127.0.0.1:11434", + }, + ); const onOpenModelSettings = vi.fn(); render( @@ -860,17 +874,31 @@ describe("ThreadShell", () => { ), ); - const badge = await screen.findByRole("button", { name: "Model not configured" }); - expect(screen.getByTestId("composer-model-setup-icon")).toBeInTheDocument(); + const badge = await screen.findByRole("button", { name: "Choose your AI" }); + const setupIcon = screen.getByTestId("composer-model-setup-icon"); + expect(setupIcon).toBeInTheDocument(); + expect(setupIcon.parentElement).not.toHaveClass("border-amber-500/35"); expect(screen.queryByTestId("composer-model-logo-openai_codex")).not.toBeInTheDocument(); fireEvent.click(badge); - expect(onOpenModelSettings).toHaveBeenCalledTimes(1); + expect(await screen.findByRole("dialog", { name: "Choose your AI" })).toBeInTheDocument(); + expect(screen.getAllByText("Ready")).toHaveLength(3); + expect(onOpenModelSettings).not.toHaveBeenCalled(); - fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), { + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + const input = screen.getByRole("textbox", { name: "Message input" }); + fireEvent.change(input, { target: { value: "hello" }, }); - fireEvent.click(screen.getByRole("button", { name: "Configure model" })); - expect(onOpenModelSettings).toHaveBeenCalledTimes(2); + fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); + + expect(await screen.findByRole("dialog", { name: "Choose your AI" })).toBeInTheDocument(); + expect(input).toHaveValue("hello"); + fireEvent.click(screen.getByRole("button", { name: "Use an API key" })); + + expect(onOpenModelSettings).toHaveBeenCalledTimes(1); + expect(input).toHaveValue("hello"); + await waitFor(() => expect(input).toHaveFocus()); expect(client.sendMessage).not.toHaveBeenCalled(); });