Refine onboarding model setup flow

Remove composer blocking so sends always create or enter a chat and route missing-model messages through the in-thread gate. Make Utility model selection explicit by removing same-as-main sync and defaulting to each provider's utility model when available. Compact onboarding success channel cards and update static regressions for the new onboarding/gate behavior.

Alessandro committed Jul 2, 2026 at 15:48 UTC caf5c2f1293a972ebd544a46cbf1393bde811d3c
9 files changed +30 -123
plugins/_discovery/extensions/webui/onboarding-success-end/discovery-cards.html
+5 -55
@@ -250,11 +250,11 @@
250 }
251
252 .discovery-feature-card {
253 - display: flex;
254 - flex-direction: column;
255 - align-items: flex-start;
256 - min-height: 142px;
257 - padding: 0.82rem 0.9rem;
253 + display: grid;
254 + grid-template-columns: minmax(0, 1fr) auto;
255 + gap: 0.75rem;
256 + align-items: center;
257 + padding: 0.72rem 0.9rem;
258 background:
259 linear-gradient(
260 180deg,
@@ -271,7 +271,6 @@
271 gap: 0.62rem;
272 width: 100%;
273 min-width: 0;
274 - margin-bottom: 1rem;
274 }
275
276 .discovery-feature-thumb {
@@ -308,16 +307,7 @@
307 line-height: 1.25;
308 }
309
311 - .discovery-feature-desc {
312 - margin: 0.42rem 0 0;
313 - padding-bottom: 0.6rem;
314 - color: var(--color-text);
315 - font-size: 0.77rem;
316 - line-height: 1.34;
317 - }
318 -
310 .discovery-feature-card > .btn {
320 - margin-top: auto;
311 min-height: 36px;
312 padding: 0.44rem 0.78rem;
313 }
@@ -554,37 +544,6 @@
544 gap: 0.75rem;
545 }
546
557 - .discovery-feature-card {
558 - display: grid;
559 - grid-template-columns: minmax(0, 1fr) auto;
560 - gap: 0.75rem;
561 - min-height: 0;
562 - align-items: center;
563 - padding: 0.85rem 1rem;
564 - }
565 -
566 - .discovery-feature-head {
567 - grid-column: 1;
568 - min-width: 0;
569 - margin-bottom: 0;
570 - }
571 -
572 - .discovery-feature-thumb {
573 - width: 32px;
574 - height: 32px;
575 - }
576 -
577 - .discovery-feature-desc {
578 - display: none;
579 - }
580 -
581 - .discovery-feature-card > .btn {
582 - grid-column: 2;
583 - grid-row: 1;
584 - align-self: center;
585 - margin-top: 0;
586 - }
587 -
547 .discovery-cta-link {
548 margin-top: 0;
549 }
@@ -626,15 +585,6 @@
585 font-size: 1rem;
586 }
587
629 - .discovery-feature-card {
630 - grid-template-columns: minmax(0, 1fr) auto;
631 - padding-right: 3rem;
632 - }
633 -
634 - .discovery-feature-card > .btn {
635 - justify-self: end;
636 - }
637 -
588 .discovery-cli-title {
589 font-size: 0.98rem;
590 }
plugins/_onboarding/webui/onboarding-store.js
+8 -26
@@ -87,7 +87,6 @@ export const store = createStore("onboarding", {
87 providerDetails: {},
88 selectedProviderId: "",
89 selectedProviderOrigin: "cloud",
90 - sameAsMain: true,
90 userTouchedModel: {
91 chat_model: false,
92 utility_model: false,
@@ -128,7 +127,6 @@ export const store = createStore("onboarding", {
127 this.providerDetails = {};
128 this.selectedProviderId = "";
129 this.selectedProviderOrigin = "cloud";
131 - this.sameAsMain = true;
130 this.userTouchedModel = { chat_model: false, utility_model: false };
131 this.modelDropdown = {
132 chat_model: { models: [], open: false, loading: false, error: "", source: "" },
@@ -805,50 +803,34 @@ export const store = createStore("onboarding", {
803 this.config[slotKey].name = modelName;
804 this.userTouchedModel[slotKey] = true;
805 this.modelDropdown[slotKey].open = false;
808 - if (slotKey === "chat_model" && this.sameAsMain) {
809 - this.syncUtilityWithMain();
810 - }
806 },
807
808 markModelTouched(slotKey) {
809 this.userTouchedModel[slotKey] = true;
815 - if (slotKey === "chat_model" && this.sameAsMain) {
816 - this.syncUtilityWithMain();
817 - }
810 },
811
812 prepareUtilityDefaults() {
821 - ensureSlot(this.config, "utility_model");
822 - if (this.sameAsMain) {
823 - this.syncUtilityWithMain();
824 - return;
825 - }
813 const mainProvider = this.config.chat_model.provider;
827 - const meta = this.providerMeta(mainProvider);
828 - this.applyProviderToSlot("utility_model", mainProvider, meta);
829 - },
830 -
831 - syncUtilityWithMain() {
832 - ensureSlot(this.config, "utility_model");
833 - if (!this.sameAsMain || !this.config?.chat_model) return;
834 - this.config.utility_model.provider = this.config.chat_model.provider;
835 - this.config.utility_model.name = this.config.chat_model.name;
836 - this.config.utility_model.api_base = this.config.chat_model.api_base || "";
837 - this.config.utility_model.kwargs = clone(this.config.chat_model.kwargs || {});
814 + this.applyProviderToSlot("utility_model", mainProvider, this.providerMeta(mainProvider));
815 + if (!this.config.utility_model.api_base) {
816 + this.config.utility_model.api_base = this.config.chat_model.api_base || "";
817 + }
818 },
819
820 async utilityProviderChanged() {
821 const providerId = this.config.utility_model.provider;
842 - this.sameAsMain = providerId === this.config.chat_model.provider;
822 this.userTouchedModel.utility_model = false;
823 + this.config.utility_model.api_base = "";
824 this.applyProviderToSlot("utility_model", providerId, this.providerMeta(providerId));
825 + if (!this.config.utility_model.api_base && providerId === this.config.chat_model.provider) {
826 + this.config.utility_model.api_base = this.config.chat_model.api_base || "";
827 + }
828 await this.loadModels("utility_model");
829 },
830
831 async completeSetup() {
832 this.saving = true;
833 try {
851 - if (this.sameAsMain) this.syncUtilityWithMain();
834 await modelConfigStore.persistApiKeysForConfig(this.config);
835 const response = await fetchApi(`${MODEL_CONFIG_API}/model_config_set`, {
836 method: "POST",
plugins/_onboarding/webui/onboarding.html
+4 -8
@@ -821,13 +821,10 @@
821
822 <section x-show="$store.onboarding.isStep('utility')" x-transition.opacity>
823 <div class="utility-panel field-stack">
824 - <label class="soft-note">
825 - <input type="checkbox" x-model="$store.onboarding.sameAsMain" @change="$store.onboarding.syncUtilityWithMain()">
826 - Use same as Main Model
827 - </label>
824 + <div class="soft-note">The utility model handles quick internal tasks — summaries, naming, memory. A small, fast, cheap model works best here.</div>
825 <div class="field">
826 <label for="utility-provider">Utility provider</label>
830 - <select id="utility-provider" x-model="$store.onboarding.config.utility_model.provider" @change="$store.onboarding.utilityProviderChanged()" :disabled="$store.onboarding.sameAsMain">
827 + <select id="utility-provider" x-model="$store.onboarding.config.utility_model.provider" @change="$store.onboarding.utilityProviderChanged()">
828 <template x-for="provider in $store.modelConfig.getProviders('utility_model')" :key="provider.value">
829 <option :value="provider.value" x-text="provider.label"></option>
830 </template>
@@ -836,17 +833,16 @@
833 <div class="field relative-field" @click.outside="$store.onboarding.closeModelDropdown('utility_model')">
834 <label class="model-label" for="utility-model-input">Search or enter Utility Model</label>
835 <div class="model-input-row">
839 - <input id="utility-model-input" type="text" x-model="$store.onboarding.config.utility_model.name" @input="$store.onboarding.markModelTouched('utility_model')" @focus="$store.onboarding.openModelDropdown('utility_model')" :disabled="$store.onboarding.sameAsMain" placeholder="Search or enter a model">
836 + <input id="utility-model-input" type="text" x-model="$store.onboarding.config.utility_model.name" @input="$store.onboarding.markModelTouched('utility_model')" @focus="$store.onboarding.openModelDropdown('utility_model')" placeholder="Search or enter a model">
837 <button type="button"
838 class="model-refresh-button"
839 aria-label="Refresh utility model list"
840 title="Refresh model list"
844 - x-show="!$store.onboarding.sameAsMain"
841 @click="$store.onboarding.loadModels('utility_model')"
842 :disabled="$store.onboarding.modelDropdown.utility_model.loading">
843 <span class="material-symbols-outlined" x-text="$store.onboarding.modelDropdown.utility_model.loading ? 'progress_activity' : 'search'"></span>
844 </button>
849 - <div class="model-dropdown" x-show="!$store.onboarding.sameAsMain && $store.onboarding.modelDropdown.utility_model.open && !$store.onboarding.modelDropdown.utility_model.loading" x-transition.opacity>
845 + <div class="model-dropdown" x-show="$store.onboarding.modelDropdown.utility_model.open && !$store.onboarding.modelDropdown.utility_model.loading" x-transition.opacity>
846 <template x-for="model in $store.onboarding.filteredModels('utility_model')" :key="model">
847 <button type="button" class="model-item" @click="$store.onboarding.selectModel('utility_model', model)" x-text="model"></button>
848 </template>
tests/test_onboarding_static.py
+4
@@ -59,6 +59,10 @@ def test_onboarding_contains_unified_provider_step():
59 assert "Main model" in html
60 assert "Refresh model list" in html
61 assert "Search or enter Utility Model" in html
62 +
63 + # The utility model is an intentional choice: no "same as main" shortcut.
64 + assert "Use same as Main Model" not in html
65 + assert "sameAsMain" not in html + store
66 assert "Advanced Settings" in html
67 assert "selectedProviderName() + ' Docs'" in html
68 assert "openSelectedProviderDocs" in html + store
tests/test_welcome_composer_static.py
+7 -3
@@ -120,7 +120,6 @@ def test_welcome_composer_can_create_a_chat_before_sending() -> None:
120 assert "this.choice = \"\";" in gate_store
121 assert 'document.addEventListener("model-configured"' in gate_store
122 assert 'document.addEventListener("model-setup-changed"' in gate_store
123 - assert "const currentContext = globalThis.getContext?.();" in gate_store
123 assert "bypassModelGate: true" in gate_store
124 assert 'openPluginConfig("_model_config", "Advanced model configuration")' in gate_store
125 assert 'openPluginConfig("_oauth"' not in gate_store
@@ -143,13 +142,18 @@ def test_welcome_composer_can_create_a_chat_before_sending() -> None:
142 display: grid;
143 gap: 0.75rem;
144 }""" in gate_component
146 - assert "Connect a model to send" in input_store
145 + # The composer never hard-blocks sending: the in-chat gate guides users instead.
146 + assert "Connect a model to send" not in input_store
147 + assert "blocked" not in input_store
148 + assert "sendDisabled" not in input_store
149 + assert "isBlockingSend" not in gate_store
150 + assert "isBlockingSend" not in index_js
151
152
153 def test_welcome_composer_does_not_overlap_idle_progress_placeholder() -> None:
154 input_store = _read("webui/components/chat/input/input-store.js")
155
152 - assert "!!chatsStore.selected &&\n ![\"all\", \"blocked\"].includes(this._getSendState())" in input_store
156 + assert "!!chatsStore.selected &&\n this._getSendState() !== \"all\"" in input_store
157
158
159 def test_welcome_composer_buttons_keep_target_geometry_without_glow() -> None:
webui/components/chat/input/chat-bar-input.html
+1 -10
@@ -46,8 +46,7 @@
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"
50 - :disabled="$store.chatInput.sendDisabled">
49 + :class="$store.chatInput.sendButtonClass" :title="$store.chatInput.sendButtonTitle">
50 <span class="material-symbols-outlined" x-text="$store.chatInput.sendButtonIcon"></span>
51 </button>
52 </div>
@@ -388,14 +387,6 @@
387 filter: none;
388 }
389
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 -
390 #send-button:active {
391 background-color: #2b309c;
392 transform: translateY(1px) scale(0.98);
webui/components/chat/input/input-store.js
+1 -12
@@ -5,7 +5,6 @@ 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";
8
9 const ICON_MARKER_RE = /icon:\/\/([a-zA-Z0-9_]+)(\[(?:\\.|[^\]])*\])?/g;
10 const FENCE_LINE_RE = /^```([A-Za-z0-9_-]*)?$/;
@@ -84,7 +83,6 @@ const model = {
83 const hasQueue = !!messageQueueStore?.hasQueue;
84 const running = !!chatsStore.selectedContext?.running;
85
87 - if (modelGateStore?.isBlockingSend) return "blocked";
86 if (hasQueue && !hasInput) return "all";
87 if ((running || hasQueue) && hasInput) return "queue";
88 return "normal";
@@ -93,7 +91,6 @@ const model = {
91 get inputPlaceholder() {
92 if (!chatsStore.selected) return "Ask anything to start a new chat";
93 const state = this._getSendState();
96 - if (state === "blocked") return "Connect a model to send";
94 if (state === "all") return "Press Enter to send queued messages";
95 if (this.showProgressPlaceholder) return "";
96 return "Type your message here...";
@@ -102,7 +99,7 @@ const model = {
99 get showProgressPlaceholder() {
100 return (
101 !!chatsStore.selected &&
105 - !["all", "blocked"].includes(this._getSendState()) &&
102 + this._getSendState() !== "all" &&
103 !!this.progressText &&
104 !this.message
105 );
@@ -118,7 +115,6 @@ const model = {
115 // Computed: send button icon type
116 get sendButtonIcon() {
117 const state = this._getSendState();
121 - if (state === "blocked") return "settings";
118 if (state === "all") return "send_and_archive";
119 if (state === "queue") return "schedule_send";
120 return "arrow_forward";
@@ -127,7 +123,6 @@ const model = {
123 // Computed: send button CSS class
124 get sendButtonClass() {
125 const state = this._getSendState();
130 - if (state === "blocked") return "model-gate-blocked";
126 if (state === "all") return "send-queue send-all";
127 if (state === "queue") return "send-queue queue";
128 return "";
@@ -136,23 +131,17 @@ const model = {
131 // Computed: send button title
132 get sendButtonTitle() {
133 const state = this._getSendState();
139 - if (state === "blocked") return "Connect a model to send";
134 if (state === "all") return "Send all queued messages";
135 if (state === "queue") return "Add to queue";
136 return "Send message";
137 },
138
145 - get sendDisabled() {
146 - return this._getSendState() === "blocked";
147 - },
148 -
139 init() {
140 console.log("Input store initialized");
141 // Event listeners are now handled via Alpine directives in the component
142 },
143
144 async sendMessage() {
155 - if (this.sendDisabled) return;
145 this._syncMessageFromEditor();
146
147 // Capture sent prompt to per-chat history (bash-style)
webui/components/chat/model-gate-store.js
-7
@@ -21,13 +21,6 @@ export const store = createStore("modelGate", {
21 dispatching: false,
22 _initialized: false,
23
24 - get isBlockingSend() {
25 - this.init();
26 - if (!this.active || this.connected) return false;
27 - const currentContext = globalThis.getContext?.();
28 - return !this.pending?.context || !currentContext || this.pending.context === currentContext;
29 - },
30 -
24 get introText() {
25 if (this.connected) {
26 return `Model connected: ${this.connectedLabel || "ready"}`;
webui/index.js
-2
@@ -42,8 +42,6 @@ let skipOneSpeech = false;
42
43 export async function sendMessage(options = {}) {
44 try {
45 - if (!options.bypassModelGate && modelGateStore.isBlockingSend) return;
46 -
45 const hasProvidedMessage = Object.prototype.hasOwnProperty.call(options, "message");
46 let message = String(hasProvidedMessage ? options.message : inputStore.message).trim();
47 let attachmentsWithUrls = options.attachments || attachmentsStore.getAttachmentsForSending();