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.
Alessandro committed
Jul 2, 2026 at 11:46 UTC
04ecaac0cd75cb7b8fb20eee34179b20e5971594
23 files changed
+695
-285
plugins/_discovery/AGENTS.md
+1
@@ -16,6 +16,7 @@
16
- CTA actions must match supported welcome-screen action contracts.
17
- Keep card IDs unique and plugin-prefixed.
18
- 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.
19
+- Do not hide discovery cards solely because model setup is incomplete; model setup gating belongs to the chat thread.
20
21
## Work Guidance
22
plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html
+1
-1
@@ -7,7 +7,7 @@
7
<div class="discovery-slot"
8
@modal-closed.window="$store.discoveryStore.refreshCards()"
9
x-create="$store.discoveryStore.refreshCards()"
10
- x-show="!($store.welcomeStore?.banners || []).some(b => b.id === 'missing-api-key') && ($store.discoveryStore.featureCards.length > 0 || $store.discoveryStore.oauthAccountCards.length > 0 || $store.discoveryStore.hasDismissedCards)">
10
+ x-show="$store.discoveryStore.featureCards.length > 0 || $store.discoveryStore.oauthAccountCards.length > 0 || $store.discoveryStore.hasDismissedCards">
11
12
<section class="discovery-features-panel" x-show="$store.discoveryStore.featureCards.length > 0">
13
<header class="discovery-panel-header">
plugins/_model_config/AGENTS.md
+1
@@ -17,6 +17,7 @@
17
- 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.
18
- Keep provider metadata and API-key checks safe around secrets.
19
- Coordinate OAuth-backed providers with `_oauth` instead of hardcoding provider-specific auth here.
20
+- `model_config_get` exposes `model_configured` as a derived chat-model readiness flag from provider, model name, and API-key availability.
21
- 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.
22
- 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`.
23
plugins/_model_config/api/model_config_get.py
+8
@@ -36,6 +36,10 @@ class ModelConfigGet(ApiHandler):
36
key = models.get_api_key(pid)
37
api_key_status[pid] = bool(key and key.strip() and key != "None")
38
39
+ chat_model = config.get("chat_model", {}) if isinstance(config, dict) else {}
40
+ chat_provider = str(chat_model.get("provider") or "").strip()
41
+ chat_name = str(chat_model.get("name") or "").strip()
42
+
43
return {
44
"config": config,
45
"chat_providers": chat_providers,
@@ -43,4 +47,8 @@ class ModelConfigGet(ApiHandler):
47
"chat_provider_details": chat_provider_details,
48
"embedding_provider_details": embedding_provider_details,
49
"api_key_status": api_key_status,
50
+ "model_configured": model_config.is_chat_model_configured(config),
51
+ "model_configured_label": " / ".join(
52
+ part for part in (chat_provider, chat_name) if part
53
+ ),
54
}
plugins/_model_config/extensions/python/banners/_20_missing_api_key.py
-4
@@ -26,10 +26,6 @@ class MissingApiKeyCheck(Extension):
26
"cta_action": f"open-modal:{self.ONBOARDING_MODAL_PATH}",
27
"dismissible": False,
28
"source": "backend",
29
- "auto_modal_path": self.ONBOARDING_MODAL_PATH,
30
- "auto_modal_reason": "missing-api-key",
31
- "auto_modal_priority": 100,
32
- "auto_modal_surfaces": ["welcome", "chat-created"],
29
# For programmatic clients (e.g. chat composer) reusing this banner pipeline
30
"missing_providers": missing_providers,
31
})
plugins/_model_config/helpers/model_config.py
+10
@@ -671,3 +671,13 @@ def get_missing_api_key_providers(agent=None) -> list[dict]:
671
missing.append({"model_type": label, "provider": provider})
672
673
return missing
674
+
675
+
676
+def is_chat_model_configured(config: dict | None = None) -> bool:
677
+ cfg = config if isinstance(config, dict) else get_config()
678
+ chat_cfg = cfg.get("chat_model", {}) if isinstance(cfg, dict) else {}
679
+ provider = str(chat_cfg.get("provider") or "").strip()
680
+ name = str(chat_cfg.get("name") or "").strip()
681
+ if not provider or not name:
682
+ return False
683
+ return has_provider_api_key(provider.lower(), chat_cfg.get("api_key", ""), "chat")
plugins/_model_config/webui/model-config-store.js
+4
@@ -158,6 +158,8 @@ export const store = createStore("modelConfig", {
158
embeddingProviders: [],
159
chatProviderDetails: [],
160
embeddingProviderDetails: [],
161
+ modelConfigured: false,
162
+ modelConfiguredLabel: "",
163
_loaded: false,
164
165
// API Keys state (from mixin)
@@ -203,6 +205,8 @@ export const store = createStore("modelConfig", {
205
this.chatProviderDetails = data.chat_provider_details || [];
206
this.embeddingProviderDetails = data.embedding_provider_details || [];
207
this.apiKeyStatus = data.api_key_status || {};
208
+ this.modelConfigured = !!data.model_configured;
209
+ this.modelConfiguredLabel = data.model_configured_label || "";
210
const keys = {};
211
const dirty = {};
212
const seen = new Set();
plugins/_oauth/AGENTS.md
+2
@@ -28,6 +28,8 @@
28
- 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.
29
- 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.
30
- 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`.
31
+- The OAuth WebUI store owns connected-provider default selection helpers reused by settings and deferred chat setup gates.
32
+- 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.
33
- `helpers/providers/registry.py` is the source of truth for connectable OAuth providers.
34
- 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.
35
- Usage-plan metadata belongs only to connectable providers. Do not add metadata-only subscription families for providers this plugin cannot connect.
plugins/_oauth/webui/oauth-config-store.js
+67
-2
@@ -287,6 +287,15 @@ export const store = createStore("oauthConfig", {
287
return status.display_name || providerId;
288
},
289
290
+ providerDefaultModel(providerId, slotKey = "chat_model") {
291
+ const status = this.providerStatus(providerId);
292
+ const defaults = Array.isArray(status.default_models)
293
+ ? status.default_models.map((model) => String(model || "").trim()).filter(Boolean)
294
+ : [];
295
+ if (slotKey === "utility_model" && defaults[1]) return defaults[1];
296
+ return String(status.default_model || defaults[0] || "").trim();
297
+ },
298
+
299
providerUseLabel(providerId) {
300
const status = this.providerStatus(providerId);
301
return status.use_label || `Use ${status.short_name || status.display_name || providerId}`;
@@ -670,6 +679,61 @@ export const store = createStore("oauthConfig", {
679
}
680
},
681
682
+ async currentChatModelConfigured() {
683
+ try {
684
+ const response = await callJsonApi(`${MODEL_CONFIG_API}/model_config_get`, {});
685
+ return Boolean(response?.model_configured);
686
+ } catch (error) {
687
+ console.error("Could not check model configuration:", error);
688
+ return true;
689
+ }
690
+ },
691
+
692
+ async autoApplyConnectedProviderIfNeeded(providerId) {
693
+ if (!this.providerConnected(providerId)) return false;
694
+ if (await this.currentChatModelConfigured()) return false;
695
+
696
+ await this.loadModelConfig();
697
+ if (!this.modelConfig) return false;
698
+
699
+ const chatModel = this.providerDefaultModel(providerId, "chat_model");
700
+ if (!chatModel) return false;
701
+ const utilityModel = this.providerDefaultModel(providerId, "utility_model") || chatModel;
702
+ const chat = this.modelSlot("chat_model");
703
+ const utility = this.modelSlot("utility_model");
704
+ chat.provider = providerId;
705
+ chat.name = chatModel;
706
+ chat.api_base = "";
707
+ if (!chat.kwargs || typeof chat.kwargs !== "object") chat.kwargs = {};
708
+ utility.provider = providerId;
709
+ utility.name = utilityModel;
710
+ utility.api_base = "";
711
+ if (!utility.kwargs || typeof utility.kwargs !== "object") utility.kwargs = {};
712
+ this.activeModelProvider = providerId;
713
+ this.models = this.activeProviderModels();
714
+ this.modelConfigDirty = true;
715
+ this.modelSlotDirty = { chat_model: true, utility_model: true };
716
+ await this.saveModelConfigIfDirty();
717
+ return true;
718
+ },
719
+
720
+ notifyModelConfigured(providerId) {
721
+ if (typeof document === "undefined") return;
722
+ document.dispatchEvent(new CustomEvent("model-configured", {
723
+ detail: { source: "_oauth", providerId },
724
+ }));
725
+ },
726
+
727
+ async handleProviderConnected(providerId, { statusLoaded = false } = {}) {
728
+ if (!statusLoaded) await this.loadStatus();
729
+ try {
730
+ await this.autoApplyConnectedProviderIfNeeded(providerId);
731
+ } catch (error) {
732
+ void toastFrontendError(messageOf(error), "OAuth Connections");
733
+ }
734
+ this.notifyModelConfigured(providerId);
735
+ },
736
+
737
async loadStatus() {
738
if (this.loadingStatus) return;
739
this.loadingStatus = true;
@@ -788,7 +852,7 @@ export const store = createStore("oauthConfig", {
852
throw new Error(response?.error || `Could not finish ${this.providerLabel(providerId)} sign-in.`);
853
}
854
if (response.completed) {
791
- await this.loadStatus();
855
+ await this.handleProviderConnected(providerId);
856
this.clearProviderDevice(providerId);
857
this.stopPolling(providerId);
858
if (this.connectingProvider === providerId) this.connectingProvider = "";
@@ -840,6 +904,7 @@ export const store = createStore("oauthConfig", {
904
const tick = async () => {
905
await this.loadStatus();
906
if (this.providerConnected(providerId)) {
907
+ await this.handleProviderConnected(providerId, { statusLoaded: true });
908
this.clearProviderDevice(providerId);
909
this.stopCallbackPolling(providerId);
910
if (this.connectingProvider === providerId) this.connectingProvider = "";
@@ -932,7 +997,7 @@ export const store = createStore("oauthConfig", {
997
this.providerUiFor(providerId).manualCallback = "";
998
this.clearProviderDevice(providerId);
999
this.stopCallbackPolling(providerId);
935
- await this.loadStatus();
1000
+ await this.handleProviderConnected(providerId);
1001
void toastFrontendSuccess(`${this.providerLabel(providerId)} connected.`, "OAuth Connections");
1002
} catch (error) {
1003
void toastFrontendError(messageOf(error), "OAuth Connections");
tests/test_model_config_api_keys.py
+29
-9
@@ -69,6 +69,25 @@ def test_model_config_api_keys_can_be_cleared_via_backend(monkeypatch, tmp_path)
69
assert handler._reveal_key({"provider": "openrouter"}) == {"ok": True, "value": ""}
70
71
72
+def test_chat_model_configured_requires_identity_and_key(monkeypatch):
73
+ from plugins._model_config.helpers import model_config
74
+
75
+ monkeypatch.setattr(
76
+ model_config,
77
+ "has_provider_api_key",
78
+ lambda provider, configured_api_key="", model_type="chat": provider == "openrouter",
79
+ )
80
+
81
+ assert not model_config.is_chat_model_configured({"chat_model": {}})
82
+ assert not model_config.is_chat_model_configured({"chat_model": {"provider": "openrouter"}})
83
+ assert model_config.is_chat_model_configured(
84
+ {"chat_model": {"provider": "openrouter", "name": "anthropic/claude"}}
85
+ )
86
+ assert not model_config.is_chat_model_configured(
87
+ {"chat_model": {"provider": "openai", "name": "gpt-5"}}
88
+ )
89
+
90
+
91
@pytest.mark.asyncio
92
async def test_missing_api_key_banner_exposes_missing_providers(monkeypatch):
93
from plugins._model_config.helpers import model_config
@@ -90,7 +109,7 @@ async def test_missing_api_key_banner_exposes_missing_providers(monkeypatch):
109
def test_model_config_frontend_tracks_inline_api_key_edits():
110
store_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-config-store.js"
111
api_keys_mixin_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "api-keys-mixin.js"
93
- composer_store_path = PROJECT_ROOT / "webui" / "components" / "chat" / "input" / "composer-banner-store.js"
112
+ model_gate_path = PROJECT_ROOT / "webui" / "components" / "chat" / "model-gate-store.js"
113
config_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "config.html"
114
model_field_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-field.html"
115
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():
119
+ "\n"
120
+ api_keys_mixin_path.read_text(encoding="utf-8")
121
)
103
- composer_store_content = composer_store_path.read_text(encoding="utf-8")
122
+ model_gate_content = model_gate_path.read_text(encoding="utf-8")
123
config_content = (
124
config_path.read_text(encoding="utf-8")
125
+ "\n"
@@ -112,9 +131,9 @@ def test_model_config_frontend_tracks_inline_api_key_edits():
131
assert "resetApiKeyDrafts()" in store_content
132
assert "!provider || seen.has(provider) || !this.apiKeyDirty[provider]" in store_content
133
assert "normalized[provider] = value.trim() ? value : '';" in store_content
115
- assert '"missing-api-key"' in composer_store_content
116
- assert 'callJsonApi("/banners"' in composer_store_content
117
- assert "/plugins/_model_config/missing_api_key_status" not in composer_store_content
134
+ assert 'callJsonApi("/plugins/_model_config/model_config_get"' in model_gate_content
135
+ assert "dispatchPendingIfConfigured()" in model_gate_content
136
+ assert "/plugins/_model_config/missing_api_key_status" not in model_gate_content
137
assert "$store.modelConfig.resetApiKeyDrafts();" in config_content
138
assert '@input="$store.modelConfig.setApiKeyValue(_prov, $el.value)"' in config_content
139
assert "persistAllDirtyApiKeys()" in modal_content
@@ -251,7 +270,7 @@ def test_ollama_cloud_provider_config_requires_key_and_base_url():
270
assert "api_key_mode" not in ollama_cloud
271
272
254
-def test_missing_api_key_banner_includes_auto_modal_metadata(monkeypatch):
273
+def test_missing_api_key_banner_does_not_include_auto_modal_metadata(monkeypatch):
274
from plugins._model_config.helpers import model_config
275
276
fake = [{"model_type": "Chat Model", "provider": "openai"}]
@@ -267,9 +286,10 @@ def test_missing_api_key_banner_includes_auto_modal_metadata(monkeypatch):
286
import asyncio
287
row = asyncio.run(run())
288
270
- assert row["auto_modal_path"] == "/plugins/_onboarding/webui/onboarding.html"
271
- assert row["auto_modal_reason"] == "missing-api-key"
272
- assert row["auto_modal_priority"] == 100
289
+ assert "auto_modal_path" not in row
290
+ assert "auto_modal_reason" not in row
291
+ assert "auto_modal_priority" not in row
292
+ assert "auto_modal_surfaces" not in row
293
assert row["type"] == "warning"
294
assert row["dismissible"] is False
295
assert row["missing_providers"] == fake
tests/test_oauth_static.py
+9
@@ -116,6 +116,14 @@ def test_oauth_model_slots_reuse_model_config_api():
116
assert "if (!this.providerConnected(providerId)) return;" in store_js
117
assert "const providerId = slot.provider;" in store_js
118
assert "const providerId = this.isOauthProvider(this.activeModelProvider)" not in store_js
119
+ assert "autoApplyConnectedProviderIfNeeded(providerId)" in store_js
120
+ assert "currentChatModelConfigured()" in store_js
121
+ assert 'providerDefaultModel(providerId, "chat_model")' in store_js
122
+ assert 'providerDefaultModel(providerId, "utility_model")' in store_js
123
+ assert "this.modelSlotDirty = { chat_model: true, utility_model: true };" in store_js
124
+ assert 'new CustomEvent("model-configured"' in store_js
125
+ assert 'detail: { source: "_oauth", providerId }' in store_js
126
+ assert "await this.handleProviderConnected(providerId)" in store_js
127
128
129
def test_browser_callback_completion_is_observed_from_modal():
@@ -124,6 +132,7 @@ def test_browser_callback_completion_is_observed_from_modal():
132
assert "startCallbackPolling(providerId)" in store_js
133
assert "stopCallbackPolling(providerId)" in store_js
134
assert "this.providerConnected(providerId)" in store_js
135
+ assert "await this.handleProviderConnected(providerId, { statusLoaded: true })" in store_js
136
137
138
def test_device_polling_honors_provider_interval_updates():
tests/test_welcome_composer_static.py
+49
-12
@@ -22,17 +22,14 @@ def test_welcome_screen_embeds_shared_new_chat_composer() -> None:
22
assert 'path="chat/input/chat-bar-input.html"' in welcome
23
assert "x-text=\"$store.welcomeStore.heroSubtitle\"" in welcome
24
assert "Hello! I'm Agent Zero" in welcome
25
- assert "'is-setup-required': $store.welcomeStore.hasBlockingSetupBanner" in welcome
26
- assert 'class="welcome-setup-composer"' in welcome
27
- assert "Configure your models to start chatting" in welcome
28
- assert "Start Onboarding" in welcome
29
- assert "openBlockingSetup()" in welcome
25
+ assert "is-setup-required" not in welcome
26
+ assert "welcome-setup-composer" not in welcome
27
+ assert "Configure your models to start chatting" not in welcome
28
+ assert "Start Onboarding" not in welcome
29
+ assert "openBlockingSetup()" not in welcome
30
assert '.filter((b) => b.id !== "missing-api-key")' in welcome_store
31
- assert "get blockingSetupBanner()" in welcome_store
31
assert "get heroSubtitle()" in welcome_store
33
- assert 'return "One setup step before chatting.";' in welcome_store
32
assert 'return "How can I help you today?";' in welcome_store
35
- assert "openBlockingSetup()" in welcome_store
33
assert '<h2>Quick Actions</h2>' in welcome
34
assert 'class="welcome-lower-grid"' in welcome
35
assert 'x-extension id="welcome-actions-end"' in welcome
@@ -64,9 +61,6 @@ def test_welcome_screen_embeds_shared_new_chat_composer() -> None:
61
assert 'action.startsWith("open-modal:")' in welcome_store
62
assert 'const hashIndex = path.indexOf("#");' in welcome_store
63
assert 'history.replaceState(null, "", `#${hash}`);' in welcome_store
67
- assert ".welcome-banner-html .onboarding-banner-btn-container" in welcome
68
- assert "justify-content: flex-end;" in welcome
69
- assert "justify-content: flex-start;" in welcome
64
assert ".welcome-banner-html .btn,\n .welcome-banner-html button" in welcome
65
assert "background-color: #4248f1;" in welcome
66
assert 'aria-label="Dismiss Connect Channels"' in discovery_cards
@@ -79,6 +73,7 @@ def test_welcome_screen_embeds_shared_new_chat_composer() -> None:
73
assert "discovery-account-header" in discovery_cards
74
assert "discovery-account-cta" in discovery_cards
75
assert "discovery-account-icon" not in discovery_cards
76
+ assert "some(b => b.id === 'missing-api-key')" not in discovery_cards
77
78
assert "x-if=\"$store.welcomeStore && $store.welcomeStore.isVisible\"" in index
79
assert "x-if=\"!$store.welcomeStore || !$store.welcomeStore.isVisible\"" in index
@@ -94,18 +89,60 @@ def test_welcome_screen_embeds_shared_new_chat_composer() -> None:
89
def test_welcome_composer_can_create_a_chat_before_sending() -> None:
90
input_store = _read("webui/components/chat/input/input-store.js")
91
chats_store = _read("webui/components/sidebar/chats/chats-store.js")
92
+ index_js = _read("webui/index.js")
93
+ gate_store = _read("webui/components/chat/model-gate-store.js")
94
+ gate_component = _read("webui/components/chat/model-setup-gate.html")
95
96
assert 'return "Ask anything to start a new chat";' in input_store
97
assert "if (!chatsStore.selected" in input_store
98
assert "await chatsStore.newChat()" in input_store
99
assert "return response.ctxid;" in chats_store
100
assert 'return "arrow_forward";' in input_store
101
+ assert "modelGateStore.canSendToModel()" in index_js
102
+ assert "modelGateStore.mergeSyntheticMessages(snapshot.logs, context)" in index_js
103
+ assert 'type: "model_setup_gate"' in gate_store
104
+ assert 'STORAGE_KEY = "a0:model-gate-pending:v1"' in gate_store
105
+ assert "SYNTHETIC_MESSAGE_NO_BASE = Number.MAX_SAFE_INTEGER - 2" in gate_store
106
+ assert "return synthetic.length ? [...(logs || []), ...synthetic] : logs;" in gate_store
107
+ assert "restorePending()" in gate_store
108
+ assert "savePending()" in gate_store
109
+ assert "clearSavedPending()" in gate_store
110
+ assert "sessionStorage.setItem(STORAGE_KEY" in gate_store
111
+ assert "sessionStorage.getItem(STORAGE_KEY)" in gate_store
112
+ assert "sessionStorage.removeItem(STORAGE_KEY)" in gate_store
113
+ assert 'import("/plugins/_oauth/webui/oauth-config-store.js")' in gate_store
114
+ assert "tryConnectedAccountDefaults()" in gate_store
115
+ assert "oauthConfigStore.connectedProviderCards()[0]?.provider_id" in gate_store
116
+ assert "oauthConfigStore.autoApplyConnectedProviderIfNeeded(providerId)" in gate_store
117
+ assert "const refreshed = await callJsonApi(\"/plugins/_model_config/model_config_get\", {});" in gate_store
118
+ assert "void this.dispatchPendingIfConfigured();" in gate_store
119
+ assert "this.choice = \"\";" in gate_store
120
+ assert 'document.addEventListener("model-configured"' in gate_store
121
+ assert "const currentContext = globalThis.getContext?.();" in gate_store
122
+ assert "bypassModelGate: true" in gate_store
123
+ assert 'openPluginConfig("_model_config", "Advanced model configuration")' in gate_store
124
+ assert 'openPluginConfig("_oauth", "OAuth Connections")' in gate_store
125
+ assert "Your message sends automatically once a model is connected." in gate_component
126
+ assert "openOnboarding('cloud')" in gate_component
127
+ assert "openOnboarding('local')" in gate_component
128
+ assert "openOauthConfiguration()" in gate_component
129
+ assert "Advanced model configuration" in gate_component
130
+ assert "Advanced settings" not in gate_component
131
+ assert "model-gate-fields" not in gate_component
132
+ assert "Connect model" not in gate_component
133
+ assert "accountsOpen" not in gate_store
134
+ assert "saveInlineSetup" not in gate_store
135
+ assert """.model-gate-card {
136
+ display: grid;
137
+ gap: 0.75rem;
138
+ }""" in gate_component
139
+ assert "Connect a model to send" in input_store
140
141
142
def test_welcome_composer_does_not_overlap_idle_progress_placeholder() -> None:
143
input_store = _read("webui/components/chat/input/input-store.js")
144
108
- assert "!!chatsStore.selected &&\n this._getSendState() !== \"all\"" in input_store
145
+ assert "!!chatsStore.selected &&\n ![\"all\", \"blocked\"].includes(this._getSendState())" in input_store
146
147
148
def test_welcome_composer_buttons_keep_target_geometry_without_glow() -> None:
webui/components/chat/AGENTS.md
+7
@@ -11,6 +11,7 @@
11
- `message-queue/` owns queued message display and store state.
12
- `navigation/` owns chat navigation state.
13
- `top-section/` owns chat header/top area.
14
+- `model-gate-store.js` and `model-setup-gate.html` own the deferred in-thread model setup gate.
15
16
## Local Contracts
17
@@ -19,6 +20,11 @@
20
- Do not bypass CSRF or WebSocket state-sync expectations.
21
- 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.
22
- 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.
23
+- 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.
24
+- While the setup gate is open, the composer remains typeable but send is blocked until setup succeeds.
25
+- 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.
26
+- Before blocking, the setup gate should reuse existing connected OAuth account defaults when they can make the chat model usable.
27
+- 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.
28
29
## Work Guidance
30
@@ -28,6 +34,7 @@
34
## Verification
35
36
- Smoke-test sending, queued messages, attachments, drag/drop, and navigation after visible changes.
37
+- Smoke-test the unconfigured first-send gate and automatic dispatch after model setup when touching model setup or send interception.
38
39
## Child DOX Index
40
webui/components/chat/input/chat-bar-input.html
+10
-1
@@ -46,7 +46,8 @@
46
<div id="chat-buttons-wrapper">
47
<!-- Send button -->
48
<button class="chat-button" id="send-button" aria-label="Send message" @click="$store.chatInput.sendMessage()"
49
- :class="$store.chatInput.sendButtonClass" :title="$store.chatInput.sendButtonTitle">
49
+ :class="$store.chatInput.sendButtonClass" :title="$store.chatInput.sendButtonTitle"
50
+ :disabled="$store.chatInput.sendDisabled">
51
<span class="material-symbols-outlined" x-text="$store.chatInput.sendButtonIcon"></span>
52
</button>
53
</div>
@@ -387,6 +388,14 @@
388
filter: none;
389
}
390
391
+ #send-button.model-gate-blocked,
392
+ #send-button.model-gate-blocked:hover {
393
+ background-color: color-mix(in srgb, var(--color-border) 55%, transparent);
394
+ color: color-mix(in srgb, var(--color-text) 45%, transparent);
395
+ cursor: not-allowed;
396
+ opacity: 0.78;
397
+ }
398
+
399
#send-button:active {
400
background-color: #2b309c;
401
transform: translateY(1px) scale(0.98);
webui/components/chat/input/chat-bar.html
+1
-73
@@ -3,31 +3,13 @@
3
<script type="module">
4
import { store } from "/components/chat/input/input-store.js";
5
import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
6
- import { store as composerBannerStore } from "/components/chat/input/composer-banner-store.js";
6
</script>
7
</head>
8
<body>
9
<div id="input-section" x-data>
10
<x-extension id="chat-input-start"></x-extension>
11
<template x-if="$store.chatInput">
13
- <div style="width: 100%; display: contents;"
14
- x-init="$store.composerBanner?.init()"
15
- x-effect="$store.chats?.selected && $store.composerBanner?.refresh()">
16
- <!-- Missing API keys (global model config) -->
17
- <template x-if="$store.composerBanner && $store.composerBanner.hasMissingApiKeys">
18
- <div class="composer-banner composer-banner--danger" role="alert">
19
- <span class="material-symbols-outlined composer-banner-icon" aria-hidden="true">error</span>
20
- <div class="composer-banner-text">
21
- <span class="composer-banner-title">API key missing</span>
22
- <span class="composer-banner-detail" x-text="$store.composerBanner.missingApiKeysSummaryText"></span>
23
- </div>
24
- <button type="button" class="btn btn-ok composer-banner-cta"
25
- @click="window.openModal('/plugins/_onboarding/webui/onboarding.html')">
26
- Insert API key
27
- </button>
28
- </div>
29
- </template>
30
-
12
+ <div style="width: 100%; display: contents;">
13
<!-- Message Queue section -->
14
<x-component path="chat/message-queue/message-queue.html"></x-component>
15
@@ -65,60 +47,6 @@
47
#input-section { align-items: normal !important; }
48
}
49
68
- .composer-banner {
69
- display: flex;
70
- align-items: center;
71
- gap: var(--spacing-sm);
72
- width: 100%;
73
- padding: var(--spacing-xs) var(--spacing-sm);
74
- margin-bottom: var(--spacing-xxs);
75
- background: var(--color-panel);
76
- border: 1px solid var(--color-border);
77
- border-radius: 6px;
78
- box-sizing: border-box;
79
- }
80
- .composer-banner--danger {
81
- border-left: 4px solid #F44336;
82
- }
83
- .composer-banner--danger .composer-banner-icon {
84
- color: #F44336;
85
- }
86
- .composer-banner-icon {
87
- flex-shrink: 0;
88
- font-size: 1.25rem;
89
- }
90
- .composer-banner-text {
91
- flex: 1;
92
- min-width: 0;
93
- display: flex;
94
- flex-direction: column;
95
- gap: 2px;
96
- text-align: left;
97
- }
98
- .composer-banner-title {
99
- font-weight: 600;
100
- font-size: 0.85rem;
101
- color: var(--color-text);
102
- }
103
- .composer-banner-detail {
104
- font-size: 0.78rem;
105
- color: var(--color-secondary);
106
- line-height: 1.35;
107
- word-break: break-word;
108
- }
109
- .composer-banner-cta {
110
- flex-shrink: 0;
111
- font-size: 0.8rem;
112
- padding: 0.35rem 0.65rem;
113
- }
114
- @media (max-width: 768px) {
115
- .composer-banner {
116
- flex-wrap: wrap;
117
- }
118
- .composer-banner-cta {
119
- width: 100%;
120
- }
121
- }
50
</style>
51
</body>
52
</html>
webui/components/chat/input/input-store.js
+12
-1
@@ -5,6 +5,7 @@ import { openLatest as openLatestSurface } from "/js/surfaces.js";
5
import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
6
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
7
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
8
+import { store as modelGateStore } from "/components/chat/model-gate-store.js";
9
10
const ICON_MARKER_RE = /icon:\/\/([a-zA-Z0-9_]+)(\[(?:\\.|[^\]])*\])?/g;
11
const FENCE_LINE_RE = /^```([A-Za-z0-9_-]*)?$/;
@@ -83,6 +84,7 @@ const model = {
84
const hasQueue = !!messageQueueStore?.hasQueue;
85
const running = !!chatsStore.selectedContext?.running;
86
87
+ if (modelGateStore?.isBlockingSend) return "blocked";
88
if (hasQueue && !hasInput) return "all";
89
if ((running || hasQueue) && hasInput) return "queue";
90
return "normal";
@@ -91,6 +93,7 @@ const model = {
93
get inputPlaceholder() {
94
if (!chatsStore.selected) return "Ask anything to start a new chat";
95
const state = this._getSendState();
96
+ if (state === "blocked") return "Connect a model to send";
97
if (state === "all") return "Press Enter to send queued messages";
98
if (this.showProgressPlaceholder) return "";
99
return "Type your message here...";
@@ -99,7 +102,7 @@ const model = {
102
get showProgressPlaceholder() {
103
return (
104
!!chatsStore.selected &&
102
- this._getSendState() !== "all" &&
105
+ !["all", "blocked"].includes(this._getSendState()) &&
106
!!this.progressText &&
107
!this.message
108
);
@@ -115,6 +118,7 @@ const model = {
118
// Computed: send button icon type
119
get sendButtonIcon() {
120
const state = this._getSendState();
121
+ if (state === "blocked") return "settings";
122
if (state === "all") return "send_and_archive";
123
if (state === "queue") return "schedule_send";
124
return "arrow_forward";
@@ -123,6 +127,7 @@ const model = {
127
// Computed: send button CSS class
128
get sendButtonClass() {
129
const state = this._getSendState();
130
+ if (state === "blocked") return "model-gate-blocked";
131
if (state === "all") return "send-queue send-all";
132
if (state === "queue") return "send-queue queue";
133
return "";
@@ -131,17 +136,23 @@ const model = {
136
// Computed: send button title
137
get sendButtonTitle() {
138
const state = this._getSendState();
139
+ if (state === "blocked") return "Connect a model to send";
140
if (state === "all") return "Send all queued messages";
141
if (state === "queue") return "Add to queue";
142
return "Send message";
143
},
144
145
+ get sendDisabled() {
146
+ return this._getSendState() === "blocked";
147
+ },
148
+
149
init() {
150
console.log("Input store initialized");
151
// Event listeners are now handled via Alpine directives in the component
152
},
153
154
async sendMessage() {
155
+ if (this.sendDisabled) return;
156
this._syncMessageFromEditor();
157
158
// Capture sent prompt to per-chat history (bash-style)
webui/components/chat/model-gate-store.js
new
+244
@@ -0,0 +1,244 @@
1
+import { createStore } from "/js/AlpineStore.js";
2
+import { callJsonApi } from "/js/api.js";
3
+import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
4
+import { store as onboardingStore } from "/plugins/_onboarding/webui/onboarding-store.js";
5
+import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
6
+import { toastFrontendError } from "/components/notifications/notification-store.js";
7
+
8
+const ONBOARDING_MODAL_PATH = "/plugins/_onboarding/webui/onboarding.html";
9
+const STORAGE_KEY = "a0:model-gate-pending:v1";
10
+const SYNTHETIC_MESSAGE_NO_BASE = Number.MAX_SAFE_INTEGER - 2;
11
+
12
+export const store = createStore("modelGate", {
13
+ active: false,
14
+ connected: false,
15
+ connectedLabel: "",
16
+ pending: null,
17
+ gateMessageId: "",
18
+ choice: "",
19
+ dispatching: false,
20
+ _initialized: false,
21
+
22
+ get isBlockingSend() {
23
+ this.init();
24
+ if (!this.active || this.connected) return false;
25
+ const currentContext = globalThis.getContext?.();
26
+ return !this.pending?.context || !currentContext || this.pending.context === currentContext;
27
+ },
28
+
29
+ get introText() {
30
+ if (this.connected) {
31
+ return `Model connected: ${this.connectedLabel || "ready"}`;
32
+ }
33
+ return "I'm ready to work on this — I just need a model to think with. Pick one and I'll answer right away.";
34
+ },
35
+
36
+ init() {
37
+ if (this._initialized) return;
38
+ this._initialized = true;
39
+ this.restorePending();
40
+ document.addEventListener("onboarding-configured", () => {
41
+ void this.dispatchPendingIfConfigured();
42
+ });
43
+ document.addEventListener("model-configured", () => {
44
+ void this.dispatchPendingIfConfigured();
45
+ });
46
+ document.addEventListener("modal-closed", () => {
47
+ if (this.active && !this.connected) {
48
+ this.choice = "";
49
+ this.savePending();
50
+ void this.dispatchPendingIfConfigured();
51
+ }
52
+ });
53
+ },
54
+
55
+ async canSendToModel() {
56
+ try {
57
+ const data = await callJsonApi("/plugins/_model_config/model_config_get", {});
58
+ this.applyModelStatus(data);
59
+ if (data?.model_configured) return true;
60
+ if (!(await this.tryConnectedAccountDefaults())) return false;
61
+ const refreshed = await callJsonApi("/plugins/_model_config/model_config_get", {});
62
+ this.applyModelStatus(refreshed);
63
+ return !!refreshed?.model_configured;
64
+ } catch (error) {
65
+ console.error("Could not check model configuration:", error);
66
+ return true;
67
+ }
68
+ },
69
+
70
+ async tryConnectedAccountDefaults() {
71
+ try {
72
+ const { store: oauthConfigStore } = await import("/plugins/_oauth/webui/oauth-config-store.js");
73
+ await oauthConfigStore.loadStatus();
74
+ const providerId = oauthConfigStore.connectedProviderCards()[0]?.provider_id || "";
75
+ return providerId ? oauthConfigStore.autoApplyConnectedProviderIfNeeded(providerId) : false;
76
+ } catch (error) {
77
+ console.error("Could not apply connected account defaults:", error);
78
+ return false;
79
+ }
80
+ },
81
+
82
+ applyModelStatus(data = {}) {
83
+ modelConfigStore.modelConfigured = !!data.model_configured;
84
+ modelConfigStore.modelConfiguredLabel = data.model_configured_label || "";
85
+ this.connectedLabel = data.model_configured_label || this.connectedLabel || "";
86
+ },
87
+
88
+ start({ message, attachments, messageId, context }) {
89
+ this.init();
90
+ this.active = true;
91
+ this.connected = false;
92
+ this.pending = { message, attachments, messageId, context };
93
+ this.gateMessageId = this.gateMessageId || `model-gate-${messageId}`;
94
+ this.savePending();
95
+ },
96
+
97
+ syntheticMessages(currentContext) {
98
+ this.init();
99
+ if (!this.active || !this.pending || this.pending.context !== currentContext) return [];
100
+ return [
101
+ {
102
+ no: SYNTHETIC_MESSAGE_NO_BASE,
103
+ id: this.pending.messageId,
104
+ type: "user",
105
+ content: this.pending.message,
106
+ kvps: { attachments: this.pending.attachments },
107
+ },
108
+ {
109
+ no: SYNTHETIC_MESSAGE_NO_BASE + 1,
110
+ id: this.gateMessageId,
111
+ type: "model_setup_gate",
112
+ },
113
+ ];
114
+ },
115
+
116
+ mergeSyntheticMessages(logs, currentContext) {
117
+ this.init();
118
+ const synthetic = this.syntheticMessages(currentContext);
119
+ return synthetic.length ? [...(logs || []), ...synthetic] : logs;
120
+ },
121
+
122
+ onCardCreate() {
123
+ this.init();
124
+ void this.dispatchPendingIfConfigured();
125
+ },
126
+
127
+ openOnboarding(choice) {
128
+ this.choice = choice === "local" ? "local" : "cloud";
129
+ this.savePending();
130
+ const modalPromise = window.openModal?.(ONBOARDING_MODAL_PATH);
131
+ void this.applyOnboardingChoice(this.choice);
132
+ void Promise.resolve(modalPromise).then(() => this.dispatchPendingIfConfigured());
133
+ },
134
+
135
+ async applyOnboardingChoice(choice) {
136
+ for (let attempt = 0; attempt < 40; attempt += 1) {
137
+ if (onboardingStore.config && !onboardingStore.loading) {
138
+ onboardingStore.choosePath(choice);
139
+ return;
140
+ }
141
+ await new Promise((resolve) => setTimeout(resolve, 50));
142
+ }
143
+ },
144
+
145
+ async openAdvancedModelConfiguration() {
146
+ await this.openPluginConfig("_model_config", "Advanced model configuration");
147
+ },
148
+
149
+ async openOauthConfiguration() {
150
+ await this.openPluginConfig("_oauth", "OAuth Connections");
151
+ },
152
+
153
+ async openPluginConfig(pluginName, title) {
154
+ try {
155
+ await pluginSettingsStore.openConfig(pluginName);
156
+ await this.dispatchPendingIfConfigured();
157
+ } catch (error) {
158
+ console.error(`Could not open ${pluginName} configuration:`, error);
159
+ void toastFrontendError(error?.message || "Could not open configuration.", title);
160
+ }
161
+ },
162
+
163
+ async dispatchPendingIfConfigured() {
164
+ this.init();
165
+ if (this.dispatching || !this.pending) return;
166
+ const configured = await this.canSendToModel();
167
+ if (!configured) return;
168
+
169
+ const pending = this.pending;
170
+ this.pending = null;
171
+ this.connected = true;
172
+ this.dispatching = true;
173
+ this.clearSavedPending();
174
+ try {
175
+ await globalThis.sendMessage?.({
176
+ bypassModelGate: true,
177
+ skipExtensions: true,
178
+ preserveInput: true,
179
+ message: pending.message,
180
+ attachments: pending.attachments,
181
+ messageId: pending.messageId,
182
+ context: pending.context,
183
+ });
184
+ } finally {
185
+ this.dispatching = false;
186
+ }
187
+ },
188
+
189
+ savePending() {
190
+ const message = String(this.pending?.message || "").trim();
191
+ const context = String(this.pending?.context || "");
192
+ const messageId = String(this.pending?.messageId || "");
193
+ const attachments = Array.isArray(this.pending?.attachments) ? this.pending.attachments : [];
194
+ if (!message || !context || !messageId || attachments.length) {
195
+ this.clearSavedPending();
196
+ return;
197
+ }
198
+
199
+ try {
200
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
201
+ pending: { message, attachments: [], messageId, context },
202
+ gateMessageId: this.gateMessageId || `model-gate-${messageId}`,
203
+ choice: this.choice,
204
+ }));
205
+ } catch (_error) {
206
+ // Session storage can be unavailable in private or locked-down browser modes.
207
+ }
208
+ },
209
+
210
+ restorePending() {
211
+ if (this.pending) return;
212
+
213
+ let saved = null;
214
+ try {
215
+ saved = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "null");
216
+ } catch (_error) {
217
+ this.clearSavedPending();
218
+ return;
219
+ }
220
+
221
+ const message = String(saved?.pending?.message || "").trim();
222
+ const context = String(saved?.pending?.context || "");
223
+ const messageId = String(saved?.pending?.messageId || "");
224
+ if (!message || !context || !messageId) {
225
+ this.clearSavedPending();
226
+ return;
227
+ }
228
+
229
+ this.active = true;
230
+ this.connected = false;
231
+ this.pending = { message, attachments: [], messageId, context };
232
+ this.gateMessageId = String(saved?.gateMessageId || `model-gate-${messageId}`);
233
+ this.choice = saved?.choice === "local" || saved?.choice === "cloud" ? saved.choice : "";
234
+ },
235
+
236
+ clearSavedPending() {
237
+ try {
238
+ sessionStorage.removeItem(STORAGE_KEY);
239
+ } catch (_error) {
240
+ // Ignore unavailable storage; the in-memory gate state is still authoritative.
241
+ }
242
+ },
243
+
244
+});
webui/components/chat/model-setup-gate.html
new
+179
@@ -0,0 +1,179 @@
1
+<html>
2
+<head>
3
+ <script type="module">
4
+ import { store as modelGateStore } from "/components/chat/model-gate-store.js";
5
+ </script>
6
+</head>
7
+<body>
8
+ <div x-data>
9
+ <template x-if="$store.modelGate">
10
+ <section class="model-gate" x-create="$store.modelGate.onCardCreate()">
11
+ <template x-if="$store.modelGate.connected">
12
+ <div class="model-gate-chip">
13
+ <span class="material-symbols-outlined" aria-hidden="true">check_circle</span>
14
+ <span x-text="$store.modelGate.introText"></span>
15
+ </div>
16
+ </template>
17
+
18
+ <template x-if="!$store.modelGate.connected">
19
+ <div class="model-gate-body">
20
+ <p class="model-gate-intro" x-text="$store.modelGate.introText"></p>
21
+
22
+ <div class="model-gate-card">
23
+ <div class="model-gate-fork">
24
+ <button type="button"
25
+ class="model-gate-option"
26
+ :class="{ selected: $store.modelGate.choice === 'cloud' }"
27
+ @click="$store.modelGate.openOnboarding('cloud')">
28
+ <span class="model-gate-option-title">
29
+ <span class="material-symbols-outlined" aria-hidden="true">cloud</span>
30
+ <span>Cloud provider</span>
31
+ </span>
32
+ <span class="model-gate-option-sub">API key or account connection</span>
33
+ </button>
34
+ <button type="button"
35
+ class="model-gate-option"
36
+ :class="{ selected: $store.modelGate.choice === 'local' }"
37
+ @click="$store.modelGate.openOnboarding('local')">
38
+ <span class="model-gate-option-title">
39
+ <span class="material-symbols-outlined" aria-hidden="true">memory</span>
40
+ <span>Local model</span>
41
+ </span>
42
+ <span class="model-gate-option-sub">Runs on your machine</span>
43
+ </button>
44
+ </div>
45
+
46
+ <div class="model-gate-accounts">
47
+ <span>Or connect an account: Codex, Copilot, Gemini, Grok</span>
48
+ <button type="button" @click="$store.modelGate.openOauthConfiguration()">
49
+ <span>Show accounts</span>
50
+ </button>
51
+ </div>
52
+ </div>
53
+
54
+ <p class="model-gate-footnote">
55
+ <span>Your message sends automatically once a model is connected.</span>
56
+ <button type="button" @click="$store.modelGate.openAdvancedModelConfiguration()">Advanced model configuration</button>
57
+ </p>
58
+ </div>
59
+ </template>
60
+ </section>
61
+ </template>
62
+ </div>
63
+
64
+ <style>
65
+ .model-gate {
66
+ width: min(100%, 36rem);
67
+ color: var(--color-text);
68
+ font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
69
+ }
70
+
71
+ .model-gate-body {
72
+ display: grid;
73
+ gap: 0.65rem;
74
+ }
75
+
76
+ .model-gate-intro {
77
+ margin: 0;
78
+ color: color-mix(in srgb, var(--color-text) 84%, transparent);
79
+ line-height: 1.45;
80
+ }
81
+
82
+ .model-gate-card {
83
+ display: grid;
84
+ gap: 0.75rem;
85
+ }
86
+
87
+ .model-gate-fork {
88
+ display: grid;
89
+ grid-template-columns: repeat(2, minmax(0, 1fr));
90
+ gap: 0.6rem;
91
+ }
92
+
93
+ .model-gate-option {
94
+ border: 1px solid color-mix(in srgb, var(--color-border) 76%, transparent);
95
+ border-radius: 8px;
96
+ background: color-mix(in srgb, var(--color-background) 52%, transparent);
97
+ color: inherit;
98
+ font: inherit;
99
+ text-align: left;
100
+ cursor: pointer;
101
+ }
102
+
103
+ .model-gate-option {
104
+ display: grid;
105
+ gap: 0.25rem;
106
+ padding: 0.72rem;
107
+ }
108
+
109
+ .model-gate-option:hover,
110
+ .model-gate-option:focus-visible,
111
+ .model-gate-option.selected {
112
+ border-color: color-mix(in srgb, var(--color-border) 86%, var(--color-text) 14%);
113
+ background: color-mix(in srgb, var(--color-background-hover) 60%, var(--color-panel));
114
+ outline: none;
115
+ }
116
+
117
+ .model-gate-option-title {
118
+ display: inline-flex;
119
+ align-items: center;
120
+ gap: 0.4rem;
121
+ font-weight: 560;
122
+ }
123
+
124
+ .model-gate-option-title .material-symbols-outlined {
125
+ font-size: 1.15rem;
126
+ color: color-mix(in srgb, var(--color-text) 72%, transparent);
127
+ }
128
+
129
+ .model-gate-option-sub,
130
+ .model-gate-footnote {
131
+ color: color-mix(in srgb, var(--color-text) 58%, transparent);
132
+ font-size: 0.78rem;
133
+ line-height: 1.35;
134
+ }
135
+
136
+ .model-gate-accounts,
137
+ .model-gate-footnote {
138
+ display: flex;
139
+ align-items: center;
140
+ justify-content: space-between;
141
+ gap: 0.7rem;
142
+ flex-wrap: wrap;
143
+ }
144
+
145
+ .model-gate-accounts button,
146
+ .model-gate-footnote button {
147
+ border: 0;
148
+ background: transparent;
149
+ color: color-mix(in srgb, #6f8cff 82%, var(--color-text));
150
+ font: inherit;
151
+ font-size: 0.8rem;
152
+ cursor: pointer;
153
+ padding: 0;
154
+ }
155
+
156
+ .model-gate-chip {
157
+ display: inline-flex;
158
+ align-items: center;
159
+ gap: 0.4rem;
160
+ border: 1px solid color-mix(in srgb, #7bc995 44%, var(--color-border));
161
+ border-radius: 999px;
162
+ padding: 0.35rem 0.65rem;
163
+ color: color-mix(in srgb, var(--color-text) 88%, #7bc995);
164
+ background: color-mix(in srgb, #7bc995 10%, var(--color-panel));
165
+ font-size: 0.84rem;
166
+ }
167
+
168
+ .model-gate-chip .material-symbols-outlined {
169
+ font-size: 1rem;
170
+ }
171
+
172
+ @media (max-width: 480px) {
173
+ .model-gate-fork {
174
+ grid-template-columns: 1fr;
175
+ }
176
+ }
177
+ </style>
178
+</body>
179
+</html>
webui/components/welcome/AGENTS.md
+1
@@ -16,6 +16,7 @@
16
- Banner body links may use `data-banner-action`; these actions route through the same welcome action dispatcher as CTA buttons.
17
- `open-modal:` banner actions may include a `#section-id` fragment, which updates the page hash before opening the modal so settings sections can deep-link correctly.
18
- Do not show setup prompts for already configured plugins when backend status can prevent it.
19
+- Do not replace the welcome composer with blocking model setup UI; missing model setup is deferred to the chat thread on first send.
20
- The welcome screen mounts the shared chat composer to start a new chat; keep it mutually exclusive with the normal chat input DOM.
21
- Render `system-resources` as the dedicated System Resources panel, not as a generic alert banner.
22
- Utility quick actions from welcome must keep the first screen intact; use modal/floating entry points instead of docking the right canvas beside welcome content.
webui/components/welcome/welcome-screen.html
+1
-150
@@ -19,27 +19,7 @@
19
<p x-text="$store.welcomeStore.heroSubtitle"></p>
20
</header>
21
22
- <section class="welcome-composer"
23
- :class="{ 'is-setup-required': $store.welcomeStore.hasBlockingSetupBanner }"
24
- :aria-label="$store.welcomeStore.hasBlockingSetupBanner ? 'Configure models to start chatting' : 'Start a new chat'">
25
- <template x-if="$store.welcomeStore.hasBlockingSetupBanner">
26
- <button type="button"
27
- class="welcome-setup-composer"
28
- @click="$store.welcomeStore.openBlockingSetup()"
29
- aria-label="Start onboarding">
30
- <span class="welcome-setup-icon" aria-hidden="true">
31
- <span class="material-symbols-outlined">warning</span>
32
- </span>
33
- <span class="welcome-setup-copy">
34
- <span class="welcome-setup-title">Configure your models to start chatting</span>
35
- <span class="welcome-setup-detail">Insert your API key in the onboarding wizard.</span>
36
- </span>
37
- <span class="welcome-setup-cta">
38
- <span>Start Onboarding</span>
39
- <span class="material-symbols-outlined" aria-hidden="true">arrow_forward</span>
40
- </span>
41
- </button>
42
- </template>
22
+ <section class="welcome-composer" aria-label="Start a new chat">
23
<x-component path="chat/attachments/inputPreview.html"></x-component>
24
<x-component path="chat/input/chat-bar-input.html"></x-component>
25
</section>
@@ -265,106 +245,6 @@
245
display: contents;
246
}
247
268
- .welcome-composer.is-setup-required > x-component {
269
- display: none;
270
- }
271
-
272
- .welcome-setup-composer {
273
- display: grid;
274
- grid-template-columns: auto minmax(0, 1fr) auto;
275
- align-items: center;
276
- gap: 1rem;
277
- width: 100%;
278
- min-height: 4.9rem;
279
- padding: 0.74rem 0.7rem 0.74rem 0.86rem;
280
- border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
281
- border-radius: 8px;
282
- background: color-mix(in srgb, var(--color-panel) 64%, var(--color-background) 36%);
283
- color: inherit;
284
- cursor: pointer;
285
- font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
286
- text-align: left;
287
- box-shadow:
288
- inset 0 1px 0 rgba(255, 255, 255, 0.035),
289
- 0 18px 46px rgba(0, 0, 0, 0.18);
290
- transition:
291
- border-color 0.18s ease,
292
- background-color 0.18s ease,
293
- box-shadow 0.18s ease;
294
- }
295
-
296
- .welcome-setup-composer:hover,
297
- .welcome-setup-composer:focus-visible {
298
- border-color: color-mix(in srgb, var(--color-border) 88%, var(--color-text) 12%);
299
- background: color-mix(in srgb, var(--color-panel) 74%, var(--color-background) 26%);
300
- box-shadow:
301
- inset 0 1px 0 rgba(255, 255, 255, 0.045),
302
- 0 22px 54px rgba(0, 0, 0, 0.22);
303
- outline: none;
304
- }
305
-
306
- .welcome-setup-icon {
307
- display: inline-flex;
308
- align-items: center;
309
- justify-content: center;
310
- width: 2.5rem;
311
- height: 2.5rem;
312
- border-radius: 8px;
313
- color: #f59e0b;
314
- background: color-mix(in srgb, #f59e0b 9%, transparent);
315
- box-shadow: inset 0 0 0 1px color-mix(in srgb, #f59e0b 12%, transparent);
316
- }
317
-
318
- .welcome-setup-icon .material-symbols-outlined {
319
- font-size: 1.35rem;
320
- }
321
-
322
- .welcome-setup-copy {
323
- display: grid;
324
- gap: 0.18rem;
325
- min-width: 0;
326
- }
327
-
328
- .welcome-setup-title {
329
- color: var(--color-text);
330
- font-size: 1rem;
331
- font-weight: 520;
332
- line-height: 1.25;
333
- }
334
-
335
- .welcome-setup-detail {
336
- color: color-mix(in srgb, var(--color-text) 58%, transparent);
337
- font-size: 0.92rem;
338
- line-height: 1.35;
339
- }
340
-
341
- .welcome-setup-cta {
342
- display: inline-flex;
343
- align-items: center;
344
- justify-content: center;
345
- gap: 0.36rem;
346
- min-height: 2.75rem;
347
- border-radius: 12px;
348
- padding: 0.62rem 0.95rem;
349
- background-color: #4248f1;
350
- color: #fff;
351
- font-size: 0.92rem;
352
- font-weight: 500;
353
- line-height: 1;
354
- white-space: nowrap;
355
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2);
356
- transition: background-color 0.18s ease;
357
- }
358
-
359
- .welcome-setup-composer:hover .welcome-setup-cta,
360
- .welcome-setup-composer:focus-visible .welcome-setup-cta {
361
- background-color: #353bc5;
362
- }
363
-
364
- .welcome-setup-cta .material-symbols-outlined {
365
- font-size: 1.24rem;
366
- }
367
-
248
.welcome-composer .input-row {
249
min-height: 4.9rem;
250
padding: 0.58rem 0.7rem 0.58rem 0.86rem;
@@ -479,14 +359,6 @@
359
text-decoration: underline;
360
}
361
482
- .welcome-banner-html .onboarding-banner-btn-container {
483
- display: flex;
484
- flex-wrap: wrap;
485
- justify-content: flex-end;
486
- gap: 0.55rem;
487
- margin-top: 0.9rem !important;
488
- }
489
-
362
.welcome-banner-cta,
363
.welcome-banner-html .btn,
364
.welcome-banner-html button {
@@ -875,22 +747,6 @@
747
margin-left: auto;
748
}
749
878
- .welcome-setup-composer {
879
- grid-template-columns: auto minmax(0, 1fr);
880
- align-items: start;
881
- gap: 0.8rem;
882
- min-height: 4.35rem;
883
- padding: 0.8rem;
884
- }
885
-
886
- .welcome-setup-cta {
887
- grid-column: 2;
888
- justify-self: start;
889
- min-height: 2.45rem;
890
- border-radius: 8px;
891
- padding: 0.55rem 0.85rem;
892
- }
893
-
750
.welcome-banner {
751
grid-template-columns: auto minmax(0, 1fr);
752
gap: 0.82rem;
@@ -912,11 +768,6 @@
768
max-width: none;
769
}
770
915
- .welcome-banner-html .onboarding-banner-btn-container {
916
- justify-content: flex-start;
917
- margin-top: 0.75rem !important;
918
- }
919
-
771
.welcome-banner-cta {
772
grid-column: 2;
773
justify-self: start;
webui/components/welcome/welcome-store.js
-18
@@ -152,28 +152,10 @@ const model = {
152
return this.banners.find((b) => b.id === "system-resources") || null;
153
},
154
155
- get blockingSetupBanner() {
156
- return this.banners.find((b) => b.id === "missing-api-key") || null;
157
- },
158
-
159
- get hasBlockingSetupBanner() {
160
- return Boolean(this.blockingSetupBanner);
161
- },
162
-
155
get heroSubtitle() {
164
- if (this.hasBlockingSetupBanner) {
165
- return "One setup step before chatting.";
166
- }
156
return "How can I help you today?";
157
},
158
170
- openBlockingSetup() {
171
- const path =
172
- this.blockingSetupBanner?.auto_modal_path ||
173
- "/plugins/_onboarding/webui/onboarding.html";
174
- window.openModal(path);
175
- },
176
-
159
executeBannerAction(action) {
160
if (!action) return;
161
webui/index.js
+41
-14
@@ -15,6 +15,7 @@ import { store as _tooltipsStore } from "/components/tooltips/tooltip-store.js";
15
import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
16
import { store as syncStore } from "/components/sync/sync-store.js"
17
import { store as welcomeStore } from "/components/welcome/welcome-store.js";
18
+import { store as modelGateStore } from "/components/chat/model-gate-store.js";
19
import { getUserHour12, getUserTimezone } from "/js/time-utils.js";
20
21
globalThis.fetchApi = api.fetchApi; // TODO - backward compatibility for non-modular scripts, remove once refactored to alpine
@@ -39,17 +40,24 @@ let skipOneSpeech = false;
40
41
// Sidebar toggle logic is now handled by sidebar-store.js
42
42
-export async function sendMessage() {
43
+export async function sendMessage(options = {}) {
44
try {
44
- let message = inputStore.message.trim();
45
- let attachmentsWithUrls = attachmentsStore.getAttachmentsForSending();
46
- const hasAttachments = attachmentsWithUrls.length > 0;
45
+ if (!options.bypassModelGate && modelGateStore.isBlockingSend) return;
46
48
- const sendCtx = { message, attachments: attachmentsWithUrls, context, cancel: false };
49
- await callJsExtensions("send_message_before", sendCtx);
47
+ const hasProvidedMessage = Object.prototype.hasOwnProperty.call(options, "message");
48
+ let message = String(hasProvidedMessage ? options.message : inputStore.message).trim();
49
+ let attachmentsWithUrls = options.attachments || attachmentsStore.getAttachmentsForSending();
50
+ let hasAttachments = attachmentsWithUrls.length > 0;
51
+
52
+ const sendCtx = { message, attachments: attachmentsWithUrls, context: options.context || context, cancel: false };
53
+ if (!options.skipExtensions) await callJsExtensions("send_message_before", sendCtx);
54
if (sendCtx.cancel) return;
55
message = sendCtx.message;
56
attachmentsWithUrls = sendCtx.attachments;
57
+ hasAttachments = attachmentsWithUrls.length > 0;
58
+ const sendContext = options.context || context;
59
+ const messageId = options.messageId || generateGUID();
60
+ const shouldResetInput = !hasProvidedMessage && !options.preserveInput;
61
62
// If empty input but has queued messages, send all queued
63
if (!message && !hasAttachments && messageQueueStore.hasQueue) {
@@ -58,12 +66,30 @@ export async function sendMessage() {
66
}
67
68
if (message || hasAttachments) {
69
+ if (!options.bypassModelGate && !(await modelGateStore.canSendToModel())) {
70
+ modelGateStore.start({
71
+ message,
72
+ attachments: attachmentsWithUrls,
73
+ messageId,
74
+ context: sendContext,
75
+ });
76
+
77
+ if (shouldResetInput) {
78
+ inputStore.reset();
79
+ adjustTextareaHeight();
80
+ }
81
+
82
+ await setMessages(modelGateStore.syntheticMessages(sendContext));
83
+ forceScrollChatToBottom();
84
+ return;
85
+ }
86
+
87
// Check if agent is busy - queue instead of sending
88
if (chatsStore.selectedContext?.running || messageQueueStore.hasQueue) {
89
const success = messageQueueStore.addToQueue(message, attachmentsWithUrls);
90
// no await for the queue
91
// if (success) {
66
- inputStore.reset();
92
+ if (shouldResetInput) inputStore.reset();
93
adjustTextareaHeight();
94
// }
95
return;
@@ -74,11 +100,12 @@ export async function sendMessage() {
100
forceScrollChatToBottom();
101
102
let response;
77
- const messageId = generateGUID();
103
79
- // Clear input and attachments
80
- inputStore.reset();
81
- adjustTextareaHeight();
104
+ // Clear input and attachments
105
+ if (shouldResetInput) {
106
+ inputStore.reset();
107
+ adjustTextareaHeight();
108
+ }
109
110
// Include attachments in the user message
111
if (hasAttachments) {
@@ -97,7 +124,7 @@ export async function sendMessage() {
124
125
const formData = new FormData();
126
formData.append("text", message);
100
- formData.append("context", context);
127
+ formData.append("context", sendContext);
128
formData.append("message_id", messageId);
129
130
for (let i = 0; i < attachmentsWithUrls.length; i++) {
@@ -112,7 +139,7 @@ export async function sendMessage() {
139
// For text-only messages
140
const data = {
141
text: message,
115
- context,
142
+ context: sendContext,
143
message_id: messageId,
144
};
145
response = await api.fetchApi("/message_async", {
@@ -352,7 +379,7 @@ export async function applySnapshot(snapshot, options = {}) {
379
const chatHistoryEl = document.getElementById("chat-history");
380
if (chatHistoryEl) chatHistoryEl.innerHTML = "";
381
}
355
- await setMessages(snapshot.logs);
382
+ await setMessages(modelGateStore.mergeSyntheticMessages(snapshot.logs, context));
383
afterMessagesUpdate(snapshot.logs);
384
}
385
webui/js/messages.js
+18
@@ -119,6 +119,8 @@ export async function getMessageHandler(type) {
119
return drawMessageUtil;
120
case "hint":
121
return drawMessageHint;
122
+ case "model_setup_gate":
123
+ return drawMessageModelSetupGate;
124
default:
125
return await getHandlerFromExtensions(type);
126
}
@@ -966,6 +968,22 @@ export function drawMessageResponse({
968
return { element: container };
969
}
970
971
+export function drawMessageModelSetupGate({ id }) {
972
+ const container = getOrCreateMessageContainer(id, "left");
973
+ container.classList.add("model-setup-gate-container");
974
+ container.innerHTML = "";
975
+
976
+ const messageDiv = document.createElement("div");
977
+ messageDiv.className = "message message-agent-response model-setup-gate-message";
978
+
979
+ const component = document.createElement("x-component");
980
+ component.setAttribute("path", "chat/model-setup-gate.html");
981
+ messageDiv.appendChild(component);
982
+ container.appendChild(messageDiv);
983
+
984
+ return { element: container };
985
+}
986
+
987
/**
988
* @param {MessageHandlerArgs & Record<string, any>} param0
989
* @returns {MessageHandlerResult}