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();