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 @@