From 04ecaac0cd75cb7b8fb20eee34179b20e5971594 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:46:01 +0200 Subject: [PATCH] Add deferred first-send model gate Move missing-model setup out of the welcome screen and into an in-thread gate that preserves the pending prompt, survives refresh, and auto-dispatches after a usable model is configured. Reuse existing onboarding, OAuth account settings, and advanced model configuration surfaces instead of duplicating provider forms. Connected OAuth accounts can now populate Main and Utility defaults when no chat model is configured. Update model readiness checks, focused static regressions, and DOX contracts for the new deferred setup flow. --- plugins/_discovery/AGENTS.md | 1 + .../welcome-actions-end/discovery-cards.html | 2 +- plugins/_model_config/AGENTS.md | 1 + plugins/_model_config/api/model_config_get.py | 8 + .../python/banners/_20_missing_api_key.py | 4 - plugins/_model_config/helpers/model_config.py | 10 + .../_model_config/webui/model-config-store.js | 4 + plugins/_oauth/AGENTS.md | 2 + plugins/_oauth/webui/oauth-config-store.js | 69 ++++- tests/test_model_config_api_keys.py | 38 ++- tests/test_oauth_static.py | 9 + tests/test_welcome_composer_static.py | 61 ++++- webui/components/chat/AGENTS.md | 7 + .../components/chat/input/chat-bar-input.html | 11 +- webui/components/chat/input/chat-bar.html | 74 +----- webui/components/chat/input/input-store.js | 13 +- webui/components/chat/model-gate-store.js | 244 ++++++++++++++++++ webui/components/chat/model-setup-gate.html | 179 +++++++++++++ webui/components/welcome/AGENTS.md | 1 + webui/components/welcome/welcome-screen.html | 151 +---------- webui/components/welcome/welcome-store.js | 18 -- webui/index.js | 55 +++- webui/js/messages.js | 18 ++ 23 files changed, 695 insertions(+), 285 deletions(-) create mode 100644 webui/components/chat/model-gate-store.js create mode 100644 webui/components/chat/model-setup-gate.html diff --git a/plugins/_discovery/AGENTS.md b/plugins/_discovery/AGENTS.md index 0a41f079b..91a769b8b 100644 --- a/plugins/_discovery/AGENTS.md +++ b/plugins/_discovery/AGENTS.md @@ -16,6 +16,7 @@ - CTA actions must match supported welcome-screen action contracts. - Keep card IDs unique and plugin-prefixed. - The Welcome screen `welcome-actions-end` surface renders feature channel cards plus the compact OAuth account-provider card; other hero discovery cards stay out of the lower welcome grid. +- Do not hide discovery cards solely because model setup is incomplete; model setup gating belongs to the chat thread. ## Work Guidance diff --git a/plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html b/plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html index 1d6ad7ca9..23f483b0e 100644 --- a/plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html +++ b/plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html @@ -7,7 +7,7 @@
+ x-show="$store.discoveryStore.featureCards.length > 0 || $store.discoveryStore.oauthAccountCards.length > 0 || $store.discoveryStore.hasDismissedCards">
diff --git a/plugins/_model_config/AGENTS.md b/plugins/_model_config/AGENTS.md index e263cb364..66b4c8aae 100644 --- a/plugins/_model_config/AGENTS.md +++ b/plugins/_model_config/AGENTS.md @@ -17,6 +17,7 @@ - Project Settings `llm` payloads are owned here through the generic `helpers.projects` project extension-data hooks; keep project helper code agnostic to `_model_config` paths, presets, and inheritance rules. - Keep provider metadata and API-key checks safe around secrets. - Coordinate OAuth-backed providers with `_oauth` instead of hardcoding provider-specific auth here. +- `model_config_get` exposes `model_configured` as a derived chat-model readiness flag from provider, model name, and API-key availability. - Applying a model preset may inherit durable tuning such as context windows and rate limits, but must replace or clear per-slot `kwargs` so provider-specific extra params never leak across model providers. - Repair provider-specific model-config aliases at the model-config read/build boundary; keep provider-specific repairs out of provider-agnostic core wrappers such as `models.py`. diff --git a/plugins/_model_config/api/model_config_get.py b/plugins/_model_config/api/model_config_get.py index cf660e2ba..1dce9e7f4 100644 --- a/plugins/_model_config/api/model_config_get.py +++ b/plugins/_model_config/api/model_config_get.py @@ -36,6 +36,10 @@ class ModelConfigGet(ApiHandler): key = models.get_api_key(pid) api_key_status[pid] = bool(key and key.strip() and key != "None") + chat_model = config.get("chat_model", {}) if isinstance(config, dict) else {} + chat_provider = str(chat_model.get("provider") or "").strip() + chat_name = str(chat_model.get("name") or "").strip() + return { "config": config, "chat_providers": chat_providers, @@ -43,4 +47,8 @@ class ModelConfigGet(ApiHandler): "chat_provider_details": chat_provider_details, "embedding_provider_details": embedding_provider_details, "api_key_status": api_key_status, + "model_configured": model_config.is_chat_model_configured(config), + "model_configured_label": " / ".join( + part for part in (chat_provider, chat_name) if part + ), } diff --git a/plugins/_model_config/extensions/python/banners/_20_missing_api_key.py b/plugins/_model_config/extensions/python/banners/_20_missing_api_key.py index 1d58b3d8e..4bc8002d2 100644 --- a/plugins/_model_config/extensions/python/banners/_20_missing_api_key.py +++ b/plugins/_model_config/extensions/python/banners/_20_missing_api_key.py @@ -26,10 +26,6 @@ class MissingApiKeyCheck(Extension): "cta_action": f"open-modal:{self.ONBOARDING_MODAL_PATH}", "dismissible": False, "source": "backend", - "auto_modal_path": self.ONBOARDING_MODAL_PATH, - "auto_modal_reason": "missing-api-key", - "auto_modal_priority": 100, - "auto_modal_surfaces": ["welcome", "chat-created"], # For programmatic clients (e.g. chat composer) reusing this banner pipeline "missing_providers": missing_providers, }) diff --git a/plugins/_model_config/helpers/model_config.py b/plugins/_model_config/helpers/model_config.py index 7632ad94f..bb79c27c3 100644 --- a/plugins/_model_config/helpers/model_config.py +++ b/plugins/_model_config/helpers/model_config.py @@ -671,3 +671,13 @@ def get_missing_api_key_providers(agent=None) -> list[dict]: missing.append({"model_type": label, "provider": provider}) return missing + + +def is_chat_model_configured(config: dict | None = None) -> bool: + cfg = config if isinstance(config, dict) else get_config() + chat_cfg = cfg.get("chat_model", {}) if isinstance(cfg, dict) else {} + provider = str(chat_cfg.get("provider") or "").strip() + name = str(chat_cfg.get("name") or "").strip() + if not provider or not name: + return False + return has_provider_api_key(provider.lower(), chat_cfg.get("api_key", ""), "chat") diff --git a/plugins/_model_config/webui/model-config-store.js b/plugins/_model_config/webui/model-config-store.js index d23f200a0..c574b84cd 100644 --- a/plugins/_model_config/webui/model-config-store.js +++ b/plugins/_model_config/webui/model-config-store.js @@ -158,6 +158,8 @@ export const store = createStore("modelConfig", { embeddingProviders: [], chatProviderDetails: [], embeddingProviderDetails: [], + modelConfigured: false, + modelConfiguredLabel: "", _loaded: false, // API Keys state (from mixin) @@ -203,6 +205,8 @@ export const store = createStore("modelConfig", { this.chatProviderDetails = data.chat_provider_details || []; this.embeddingProviderDetails = data.embedding_provider_details || []; this.apiKeyStatus = data.api_key_status || {}; + this.modelConfigured = !!data.model_configured; + this.modelConfiguredLabel = data.model_configured_label || ""; const keys = {}; const dirty = {}; const seen = new Set(); diff --git a/plugins/_oauth/AGENTS.md b/plugins/_oauth/AGENTS.md index 1bbf87f9e..e56973c19 100644 --- a/plugins/_oauth/AGENTS.md +++ b/plugins/_oauth/AGENTS.md @@ -28,6 +28,8 @@ - OAuth settings pending-auth controls such as device codes, manual callback input, and provider setup fields must render inline under the relevant provider row, not as a detached section below all providers. - OAuth device-code polling must honor provider `interval`, `expires_at`, and `slow_down` updates; do not poll immediately or keep a stale fixed interval after a provider asks the client to slow down. - OAuth settings model slots must keep provider choice editable per slot, list only connected OAuth account providers, and persist the selected provider IDs into `chat_model.provider` and `utility_model.provider`. +- The OAuth WebUI store owns connected-provider default selection helpers reused by settings and deferred chat setup gates. +- OAuth settings must dispatch `model-configured` when a provider connection completes. If no chat model is configured, the settings flow may apply the connected provider defaults to Main and Utility before notifying deferred send gates. - `helpers/providers/registry.py` is the source of truth for connectable OAuth providers. - OAuth provider config must not expose the dummy `oauth` API key in `conf/model_providers.yaml`; the dummy key is a runtime-only shim supplied by the `get_api_key` extension after the account provider reports connected. - Usage-plan metadata belongs only to connectable providers. Do not add metadata-only subscription families for providers this plugin cannot connect. diff --git a/plugins/_oauth/webui/oauth-config-store.js b/plugins/_oauth/webui/oauth-config-store.js index 6e2b2201d..2f0fe1447 100644 --- a/plugins/_oauth/webui/oauth-config-store.js +++ b/plugins/_oauth/webui/oauth-config-store.js @@ -287,6 +287,15 @@ export const store = createStore("oauthConfig", { return status.display_name || providerId; }, + providerDefaultModel(providerId, slotKey = "chat_model") { + const status = this.providerStatus(providerId); + const defaults = Array.isArray(status.default_models) + ? status.default_models.map((model) => String(model || "").trim()).filter(Boolean) + : []; + if (slotKey === "utility_model" && defaults[1]) return defaults[1]; + return String(status.default_model || defaults[0] || "").trim(); + }, + providerUseLabel(providerId) { const status = this.providerStatus(providerId); return status.use_label || `Use ${status.short_name || status.display_name || providerId}`; @@ -670,6 +679,61 @@ export const store = createStore("oauthConfig", { } }, + async currentChatModelConfigured() { + try { + const response = await callJsonApi(`${MODEL_CONFIG_API}/model_config_get`, {}); + return Boolean(response?.model_configured); + } catch (error) { + console.error("Could not check model configuration:", error); + return true; + } + }, + + async autoApplyConnectedProviderIfNeeded(providerId) { + if (!this.providerConnected(providerId)) return false; + if (await this.currentChatModelConfigured()) return false; + + await this.loadModelConfig(); + if (!this.modelConfig) return false; + + const chatModel = this.providerDefaultModel(providerId, "chat_model"); + if (!chatModel) return false; + const utilityModel = this.providerDefaultModel(providerId, "utility_model") || chatModel; + const chat = this.modelSlot("chat_model"); + const utility = this.modelSlot("utility_model"); + chat.provider = providerId; + chat.name = chatModel; + chat.api_base = ""; + if (!chat.kwargs || typeof chat.kwargs !== "object") chat.kwargs = {}; + utility.provider = providerId; + utility.name = utilityModel; + utility.api_base = ""; + if (!utility.kwargs || typeof utility.kwargs !== "object") utility.kwargs = {}; + this.activeModelProvider = providerId; + this.models = this.activeProviderModels(); + this.modelConfigDirty = true; + this.modelSlotDirty = { chat_model: true, utility_model: true }; + await this.saveModelConfigIfDirty(); + return true; + }, + + notifyModelConfigured(providerId) { + if (typeof document === "undefined") return; + document.dispatchEvent(new CustomEvent("model-configured", { + detail: { source: "_oauth", providerId }, + })); + }, + + async handleProviderConnected(providerId, { statusLoaded = false } = {}) { + if (!statusLoaded) await this.loadStatus(); + try { + await this.autoApplyConnectedProviderIfNeeded(providerId); + } catch (error) { + void toastFrontendError(messageOf(error), "OAuth Connections"); + } + this.notifyModelConfigured(providerId); + }, + async loadStatus() { if (this.loadingStatus) return; this.loadingStatus = true; @@ -788,7 +852,7 @@ export const store = createStore("oauthConfig", { throw new Error(response?.error || `Could not finish ${this.providerLabel(providerId)} sign-in.`); } if (response.completed) { - await this.loadStatus(); + await this.handleProviderConnected(providerId); this.clearProviderDevice(providerId); this.stopPolling(providerId); if (this.connectingProvider === providerId) this.connectingProvider = ""; @@ -840,6 +904,7 @@ export const store = createStore("oauthConfig", { const tick = async () => { await this.loadStatus(); if (this.providerConnected(providerId)) { + await this.handleProviderConnected(providerId, { statusLoaded: true }); this.clearProviderDevice(providerId); this.stopCallbackPolling(providerId); if (this.connectingProvider === providerId) this.connectingProvider = ""; @@ -932,7 +997,7 @@ export const store = createStore("oauthConfig", { this.providerUiFor(providerId).manualCallback = ""; this.clearProviderDevice(providerId); this.stopCallbackPolling(providerId); - await this.loadStatus(); + await this.handleProviderConnected(providerId); void toastFrontendSuccess(`${this.providerLabel(providerId)} connected.`, "OAuth Connections"); } catch (error) { void toastFrontendError(messageOf(error), "OAuth Connections"); diff --git a/tests/test_model_config_api_keys.py b/tests/test_model_config_api_keys.py index acd565a45..18b41f4ff 100644 --- a/tests/test_model_config_api_keys.py +++ b/tests/test_model_config_api_keys.py @@ -69,6 +69,25 @@ def test_model_config_api_keys_can_be_cleared_via_backend(monkeypatch, tmp_path) assert handler._reveal_key({"provider": "openrouter"}) == {"ok": True, "value": ""} +def test_chat_model_configured_requires_identity_and_key(monkeypatch): + from plugins._model_config.helpers import model_config + + monkeypatch.setattr( + model_config, + "has_provider_api_key", + lambda provider, configured_api_key="", model_type="chat": provider == "openrouter", + ) + + assert not model_config.is_chat_model_configured({"chat_model": {}}) + assert not model_config.is_chat_model_configured({"chat_model": {"provider": "openrouter"}}) + assert model_config.is_chat_model_configured( + {"chat_model": {"provider": "openrouter", "name": "anthropic/claude"}} + ) + assert not model_config.is_chat_model_configured( + {"chat_model": {"provider": "openai", "name": "gpt-5"}} + ) + + @pytest.mark.asyncio async def test_missing_api_key_banner_exposes_missing_providers(monkeypatch): from plugins._model_config.helpers import model_config @@ -90,7 +109,7 @@ async def test_missing_api_key_banner_exposes_missing_providers(monkeypatch): def test_model_config_frontend_tracks_inline_api_key_edits(): store_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-config-store.js" api_keys_mixin_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "api-keys-mixin.js" - composer_store_path = PROJECT_ROOT / "webui" / "components" / "chat" / "input" / "composer-banner-store.js" + model_gate_path = PROJECT_ROOT / "webui" / "components" / "chat" / "model-gate-store.js" config_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "config.html" model_field_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-field.html" modal_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "api-keys.html" @@ -100,7 +119,7 @@ def test_model_config_frontend_tracks_inline_api_key_edits(): + "\n" + api_keys_mixin_path.read_text(encoding="utf-8") ) - composer_store_content = composer_store_path.read_text(encoding="utf-8") + model_gate_content = model_gate_path.read_text(encoding="utf-8") config_content = ( config_path.read_text(encoding="utf-8") + "\n" @@ -112,9 +131,9 @@ def test_model_config_frontend_tracks_inline_api_key_edits(): assert "resetApiKeyDrafts()" in store_content assert "!provider || seen.has(provider) || !this.apiKeyDirty[provider]" in store_content assert "normalized[provider] = value.trim() ? value : '';" in store_content - assert '"missing-api-key"' in composer_store_content - assert 'callJsonApi("/banners"' in composer_store_content - assert "/plugins/_model_config/missing_api_key_status" not in composer_store_content + assert 'callJsonApi("/plugins/_model_config/model_config_get"' in model_gate_content + assert "dispatchPendingIfConfigured()" in model_gate_content + assert "/plugins/_model_config/missing_api_key_status" not in model_gate_content assert "$store.modelConfig.resetApiKeyDrafts();" in config_content assert '@input="$store.modelConfig.setApiKeyValue(_prov, $el.value)"' in config_content assert "persistAllDirtyApiKeys()" in modal_content @@ -251,7 +270,7 @@ def test_ollama_cloud_provider_config_requires_key_and_base_url(): assert "api_key_mode" not in ollama_cloud -def test_missing_api_key_banner_includes_auto_modal_metadata(monkeypatch): +def test_missing_api_key_banner_does_not_include_auto_modal_metadata(monkeypatch): from plugins._model_config.helpers import model_config fake = [{"model_type": "Chat Model", "provider": "openai"}] @@ -267,9 +286,10 @@ def test_missing_api_key_banner_includes_auto_modal_metadata(monkeypatch): import asyncio row = asyncio.run(run()) - assert row["auto_modal_path"] == "/plugins/_onboarding/webui/onboarding.html" - assert row["auto_modal_reason"] == "missing-api-key" - assert row["auto_modal_priority"] == 100 + assert "auto_modal_path" not in row + assert "auto_modal_reason" not in row + assert "auto_modal_priority" not in row + assert "auto_modal_surfaces" not in row assert row["type"] == "warning" assert row["dismissible"] is False assert row["missing_providers"] == fake diff --git a/tests/test_oauth_static.py b/tests/test_oauth_static.py index 2b5d31b51..2a47c5cf7 100644 --- a/tests/test_oauth_static.py +++ b/tests/test_oauth_static.py @@ -116,6 +116,14 @@ def test_oauth_model_slots_reuse_model_config_api(): assert "if (!this.providerConnected(providerId)) return;" in store_js assert "const providerId = slot.provider;" in store_js assert "const providerId = this.isOauthProvider(this.activeModelProvider)" not in store_js + assert "autoApplyConnectedProviderIfNeeded(providerId)" in store_js + assert "currentChatModelConfigured()" in store_js + assert 'providerDefaultModel(providerId, "chat_model")' in store_js + assert 'providerDefaultModel(providerId, "utility_model")' in store_js + assert "this.modelSlotDirty = { chat_model: true, utility_model: true };" in store_js + assert 'new CustomEvent("model-configured"' in store_js + assert 'detail: { source: "_oauth", providerId }' in store_js + assert "await this.handleProviderConnected(providerId)" in store_js def test_browser_callback_completion_is_observed_from_modal(): @@ -124,6 +132,7 @@ def test_browser_callback_completion_is_observed_from_modal(): assert "startCallbackPolling(providerId)" in store_js assert "stopCallbackPolling(providerId)" in store_js assert "this.providerConnected(providerId)" in store_js + assert "await this.handleProviderConnected(providerId, { statusLoaded: true })" in store_js def test_device_polling_honors_provider_interval_updates(): diff --git a/tests/test_welcome_composer_static.py b/tests/test_welcome_composer_static.py index c5a4dc8ad..440884e7d 100644 --- a/tests/test_welcome_composer_static.py +++ b/tests/test_welcome_composer_static.py @@ -22,17 +22,14 @@ def test_welcome_screen_embeds_shared_new_chat_composer() -> None: assert 'path="chat/input/chat-bar-input.html"' in welcome assert "x-text=\"$store.welcomeStore.heroSubtitle\"" in welcome assert "Hello! I'm Agent Zero" in welcome - assert "'is-setup-required': $store.welcomeStore.hasBlockingSetupBanner" in welcome - assert 'class="welcome-setup-composer"' in welcome - assert "Configure your models to start chatting" in welcome - assert "Start Onboarding" in welcome - assert "openBlockingSetup()" in welcome + assert "is-setup-required" not in welcome + assert "welcome-setup-composer" not in welcome + assert "Configure your models to start chatting" not in welcome + assert "Start Onboarding" not in welcome + assert "openBlockingSetup()" not in welcome assert '.filter((b) => b.id !== "missing-api-key")' in welcome_store - assert "get blockingSetupBanner()" in welcome_store assert "get heroSubtitle()" in welcome_store - assert 'return "One setup step before chatting.";' in welcome_store assert 'return "How can I help you today?";' in welcome_store - assert "openBlockingSetup()" in welcome_store assert '

Quick Actions

' in welcome assert 'class="welcome-lower-grid"' in welcome assert 'x-extension id="welcome-actions-end"' in welcome @@ -64,9 +61,6 @@ def test_welcome_screen_embeds_shared_new_chat_composer() -> None: assert 'action.startsWith("open-modal:")' in welcome_store assert 'const hashIndex = path.indexOf("#");' in welcome_store assert 'history.replaceState(null, "", `#${hash}`);' in welcome_store - assert ".welcome-banner-html .onboarding-banner-btn-container" in welcome - assert "justify-content: flex-end;" in welcome - assert "justify-content: flex-start;" in welcome assert ".welcome-banner-html .btn,\n .welcome-banner-html button" in welcome assert "background-color: #4248f1;" in welcome assert 'aria-label="Dismiss Connect Channels"' in discovery_cards @@ -79,6 +73,7 @@ def test_welcome_screen_embeds_shared_new_chat_composer() -> None: assert "discovery-account-header" in discovery_cards assert "discovery-account-cta" in discovery_cards assert "discovery-account-icon" not in discovery_cards + assert "some(b => b.id === 'missing-api-key')" not in discovery_cards assert "x-if=\"$store.welcomeStore && $store.welcomeStore.isVisible\"" in index assert "x-if=\"!$store.welcomeStore || !$store.welcomeStore.isVisible\"" in index @@ -94,18 +89,60 @@ def test_welcome_screen_embeds_shared_new_chat_composer() -> None: def test_welcome_composer_can_create_a_chat_before_sending() -> None: input_store = _read("webui/components/chat/input/input-store.js") chats_store = _read("webui/components/sidebar/chats/chats-store.js") + index_js = _read("webui/index.js") + gate_store = _read("webui/components/chat/model-gate-store.js") + gate_component = _read("webui/components/chat/model-setup-gate.html") assert 'return "Ask anything to start a new chat";' in input_store assert "if (!chatsStore.selected" in input_store assert "await chatsStore.newChat()" in input_store assert "return response.ctxid;" in chats_store assert 'return "arrow_forward";' in input_store + assert "modelGateStore.canSendToModel()" in index_js + assert "modelGateStore.mergeSyntheticMessages(snapshot.logs, context)" in index_js + assert 'type: "model_setup_gate"' in gate_store + assert 'STORAGE_KEY = "a0:model-gate-pending:v1"' in gate_store + assert "SYNTHETIC_MESSAGE_NO_BASE = Number.MAX_SAFE_INTEGER - 2" in gate_store + assert "return synthetic.length ? [...(logs || []), ...synthetic] : logs;" in gate_store + assert "restorePending()" in gate_store + assert "savePending()" in gate_store + assert "clearSavedPending()" in gate_store + assert "sessionStorage.setItem(STORAGE_KEY" in gate_store + assert "sessionStorage.getItem(STORAGE_KEY)" in gate_store + assert "sessionStorage.removeItem(STORAGE_KEY)" in gate_store + assert 'import("/plugins/_oauth/webui/oauth-config-store.js")' in gate_store + assert "tryConnectedAccountDefaults()" in gate_store + assert "oauthConfigStore.connectedProviderCards()[0]?.provider_id" in gate_store + assert "oauthConfigStore.autoApplyConnectedProviderIfNeeded(providerId)" in gate_store + assert "const refreshed = await callJsonApi(\"/plugins/_model_config/model_config_get\", {});" in gate_store + assert "void this.dispatchPendingIfConfigured();" in gate_store + assert "this.choice = \"\";" in gate_store + assert 'document.addEventListener("model-configured"' in gate_store + assert "const currentContext = globalThis.getContext?.();" in gate_store + assert "bypassModelGate: true" in gate_store + assert 'openPluginConfig("_model_config", "Advanced model configuration")' in gate_store + assert 'openPluginConfig("_oauth", "OAuth Connections")' in gate_store + assert "Your message sends automatically once a model is connected." in gate_component + assert "openOnboarding('cloud')" in gate_component + assert "openOnboarding('local')" in gate_component + assert "openOauthConfiguration()" in gate_component + assert "Advanced model configuration" in gate_component + assert "Advanced settings" not in gate_component + assert "model-gate-fields" not in gate_component + assert "Connect model" not in gate_component + assert "accountsOpen" not in gate_store + assert "saveInlineSetup" not in gate_store + assert """.model-gate-card { + display: grid; + gap: 0.75rem; + }""" in gate_component + assert "Connect a model to send" in input_store def test_welcome_composer_does_not_overlap_idle_progress_placeholder() -> None: input_store = _read("webui/components/chat/input/input-store.js") - assert "!!chatsStore.selected &&\n this._getSendState() !== \"all\"" in input_store + assert "!!chatsStore.selected &&\n ![\"all\", \"blocked\"].includes(this._getSendState())" in input_store def test_welcome_composer_buttons_keep_target_geometry_without_glow() -> None: diff --git a/webui/components/chat/AGENTS.md b/webui/components/chat/AGENTS.md index fc422316c..2dfcea70d 100644 --- a/webui/components/chat/AGENTS.md +++ b/webui/components/chat/AGENTS.md @@ -11,6 +11,7 @@ - `message-queue/` owns queued message display and store state. - `navigation/` owns chat navigation state. - `top-section/` owns chat header/top area. +- `model-gate-store.js` and `model-setup-gate.html` own the deferred in-thread model setup gate. ## Local Contracts @@ -19,6 +20,11 @@ - Do not bypass CSRF or WebSocket state-sync expectations. - The shared composer can be mounted on the Welcome screen with no selected chat; sending from that state must create and select a chat context before dispatch. - Composer text uses the main UI font by default; typing a triple-backtick fence and pressing Enter turns that line into a visual code block that serializes back to fenced Markdown, while pasted fenced Markdown stays plain text. +- Missing model setup is gated at send intent: the first unconfigured send renders an in-thread setup card, keeps the pending prompt in browser session storage for refresh recovery, and must not call `/message_async` until a chat model is configured. +- While the setup gate is open, the composer remains typeable but send is blocked until setup succeeds. +- The setup gate must delegate Cloud/Local setup, account connections, and advanced model configuration to the existing onboarding and plugin settings modals; do not duplicate provider/model/key forms inline. +- Before blocking, the setup gate should reuse existing connected OAuth account defaults when they can make the chat model usable. +- Model setup surfaces that make the chat model usable must notify the gate with `model-configured` or an existing modal/onboarding completion signal so the pending prompt can retry automatically. ## Work Guidance @@ -28,6 +34,7 @@ ## Verification - Smoke-test sending, queued messages, attachments, drag/drop, and navigation after visible changes. +- Smoke-test the unconfigured first-send gate and automatic dispatch after model setup when touching model setup or send interception. ## Child DOX Index diff --git a/webui/components/chat/input/chat-bar-input.html b/webui/components/chat/input/chat-bar-input.html index f40494da0..40a815eb9 100644 --- a/webui/components/chat/input/chat-bar-input.html +++ b/webui/components/chat/input/chat-bar-input.html @@ -46,7 +46,8 @@
@@ -387,6 +388,14 @@ filter: none; } + #send-button.model-gate-blocked, + #send-button.model-gate-blocked:hover { + background-color: color-mix(in srgb, var(--color-border) 55%, transparent); + color: color-mix(in srgb, var(--color-text) 45%, transparent); + cursor: not-allowed; + opacity: 0.78; + } + #send-button:active { background-color: #2b309c; transform: translateY(1px) scale(0.98); diff --git a/webui/components/chat/input/chat-bar.html b/webui/components/chat/input/chat-bar.html index 5e738e205..9b6b8d4cc 100644 --- a/webui/components/chat/input/chat-bar.html +++ b/webui/components/chat/input/chat-bar.html @@ -3,31 +3,13 @@