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.
This commit is contained in:
Alessandro 2026-07-02 15:48:03 +02:00
parent 8d887e6f6d
commit caf5c2f129
9 changed files with 30 additions and 123 deletions

View file

@ -250,11 +250,11 @@
}
.discovery-feature-card {
display: flex;
flex-direction: column;
align-items: flex-start;
min-height: 142px;
padding: 0.82rem 0.9rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
padding: 0.72rem 0.9rem;
background:
linear-gradient(
180deg,
@ -271,7 +271,6 @@
gap: 0.62rem;
width: 100%;
min-width: 0;
margin-bottom: 1rem;
}
.discovery-feature-thumb {
@ -308,16 +307,7 @@
line-height: 1.25;
}
.discovery-feature-desc {
margin: 0.42rem 0 0;
padding-bottom: 0.6rem;
color: var(--color-text);
font-size: 0.77rem;
line-height: 1.34;
}
.discovery-feature-card > .btn {
margin-top: auto;
min-height: 36px;
padding: 0.44rem 0.78rem;
}
@ -554,37 +544,6 @@
gap: 0.75rem;
}
.discovery-feature-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
min-height: 0;
align-items: center;
padding: 0.85rem 1rem;
}
.discovery-feature-head {
grid-column: 1;
min-width: 0;
margin-bottom: 0;
}
.discovery-feature-thumb {
width: 32px;
height: 32px;
}
.discovery-feature-desc {
display: none;
}
.discovery-feature-card > .btn {
grid-column: 2;
grid-row: 1;
align-self: center;
margin-top: 0;
}
.discovery-cta-link {
margin-top: 0;
}
@ -626,15 +585,6 @@
font-size: 1rem;
}
.discovery-feature-card {
grid-template-columns: minmax(0, 1fr) auto;
padding-right: 3rem;
}
.discovery-feature-card > .btn {
justify-self: end;
}
.discovery-cli-title {
font-size: 0.98rem;
}

View file

@ -87,7 +87,6 @@ export const store = createStore("onboarding", {
providerDetails: {},
selectedProviderId: "",
selectedProviderOrigin: "cloud",
sameAsMain: true,
userTouchedModel: {
chat_model: false,
utility_model: false,
@ -128,7 +127,6 @@ export const store = createStore("onboarding", {
this.providerDetails = {};
this.selectedProviderId = "";
this.selectedProviderOrigin = "cloud";
this.sameAsMain = true;
this.userTouchedModel = { chat_model: false, utility_model: false };
this.modelDropdown = {
chat_model: { models: [], open: false, loading: false, error: "", source: "" },
@ -805,50 +803,34 @@ export const store = createStore("onboarding", {
this.config[slotKey].name = modelName;
this.userTouchedModel[slotKey] = true;
this.modelDropdown[slotKey].open = false;
if (slotKey === "chat_model" && this.sameAsMain) {
this.syncUtilityWithMain();
}
},
markModelTouched(slotKey) {
this.userTouchedModel[slotKey] = true;
if (slotKey === "chat_model" && this.sameAsMain) {
this.syncUtilityWithMain();
}
},
prepareUtilityDefaults() {
ensureSlot(this.config, "utility_model");
if (this.sameAsMain) {
this.syncUtilityWithMain();
return;
}
const mainProvider = this.config.chat_model.provider;
const meta = this.providerMeta(mainProvider);
this.applyProviderToSlot("utility_model", mainProvider, meta);
},
syncUtilityWithMain() {
ensureSlot(this.config, "utility_model");
if (!this.sameAsMain || !this.config?.chat_model) return;
this.config.utility_model.provider = this.config.chat_model.provider;
this.config.utility_model.name = this.config.chat_model.name;
this.config.utility_model.api_base = this.config.chat_model.api_base || "";
this.config.utility_model.kwargs = clone(this.config.chat_model.kwargs || {});
this.applyProviderToSlot("utility_model", mainProvider, this.providerMeta(mainProvider));
if (!this.config.utility_model.api_base) {
this.config.utility_model.api_base = this.config.chat_model.api_base || "";
}
},
async utilityProviderChanged() {
const providerId = this.config.utility_model.provider;
this.sameAsMain = providerId === this.config.chat_model.provider;
this.userTouchedModel.utility_model = false;
this.config.utility_model.api_base = "";
this.applyProviderToSlot("utility_model", providerId, this.providerMeta(providerId));
if (!this.config.utility_model.api_base && providerId === this.config.chat_model.provider) {
this.config.utility_model.api_base = this.config.chat_model.api_base || "";
}
await this.loadModels("utility_model");
},
async completeSetup() {
this.saving = true;
try {
if (this.sameAsMain) this.syncUtilityWithMain();
await modelConfigStore.persistApiKeysForConfig(this.config);
const response = await fetchApi(`${MODEL_CONFIG_API}/model_config_set`, {
method: "POST",

View file

@ -821,13 +821,10 @@
<section x-show="$store.onboarding.isStep('utility')" x-transition.opacity>
<div class="utility-panel field-stack">
<label class="soft-note">
<input type="checkbox" x-model="$store.onboarding.sameAsMain" @change="$store.onboarding.syncUtilityWithMain()">
Use same as Main Model
</label>
<div class="soft-note">The utility model handles quick internal tasks — summaries, naming, memory. A small, fast, cheap model works best here.</div>
<div class="field">
<label for="utility-provider">Utility provider</label>
<select id="utility-provider" x-model="$store.onboarding.config.utility_model.provider" @change="$store.onboarding.utilityProviderChanged()" :disabled="$store.onboarding.sameAsMain">
<select id="utility-provider" x-model="$store.onboarding.config.utility_model.provider" @change="$store.onboarding.utilityProviderChanged()">
<template x-for="provider in $store.modelConfig.getProviders('utility_model')" :key="provider.value">
<option :value="provider.value" x-text="provider.label"></option>
</template>
@ -836,17 +833,16 @@
<div class="field relative-field" @click.outside="$store.onboarding.closeModelDropdown('utility_model')">
<label class="model-label" for="utility-model-input">Search or enter Utility Model</label>
<div class="model-input-row">
<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">
<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">
<button type="button"
class="model-refresh-button"
aria-label="Refresh utility model list"
title="Refresh model list"
x-show="!$store.onboarding.sameAsMain"
@click="$store.onboarding.loadModels('utility_model')"
:disabled="$store.onboarding.modelDropdown.utility_model.loading">
<span class="material-symbols-outlined" x-text="$store.onboarding.modelDropdown.utility_model.loading ? 'progress_activity' : 'search'"></span>
</button>
<div class="model-dropdown" x-show="!$store.onboarding.sameAsMain && $store.onboarding.modelDropdown.utility_model.open && !$store.onboarding.modelDropdown.utility_model.loading" x-transition.opacity>
<div class="model-dropdown" x-show="$store.onboarding.modelDropdown.utility_model.open && !$store.onboarding.modelDropdown.utility_model.loading" x-transition.opacity>
<template x-for="model in $store.onboarding.filteredModels('utility_model')" :key="model">
<button type="button" class="model-item" @click="$store.onboarding.selectModel('utility_model', model)" x-text="model"></button>
</template>

View file

@ -59,6 +59,10 @@ def test_onboarding_contains_unified_provider_step():
assert "Main model" in html
assert "Refresh model list" in html
assert "Search or enter Utility Model" in html
# The utility model is an intentional choice: no "same as main" shortcut.
assert "Use same as Main Model" not in html
assert "sameAsMain" not in html + store
assert "Advanced Settings" in html
assert "selectedProviderName() + ' Docs'" in html
assert "openSelectedProviderDocs" in html + store

View file

@ -120,7 +120,6 @@ def test_welcome_composer_can_create_a_chat_before_sending() -> None:
assert "this.choice = \"\";" in gate_store
assert 'document.addEventListener("model-configured"' in gate_store
assert 'document.addEventListener("model-setup-changed"' in gate_store
assert "const currentContext = globalThis.getContext?.();" in gate_store
assert "bypassModelGate: true" in gate_store
assert 'openPluginConfig("_model_config", "Advanced model configuration")' in gate_store
assert 'openPluginConfig("_oauth"' not in gate_store
@ -143,13 +142,18 @@ def test_welcome_composer_can_create_a_chat_before_sending() -> None:
display: grid;
gap: 0.75rem;
}""" in gate_component
assert "Connect a model to send" in input_store
# The composer never hard-blocks sending: the in-chat gate guides users instead.
assert "Connect a model to send" not in input_store
assert "blocked" not in input_store
assert "sendDisabled" not in input_store
assert "isBlockingSend" not in gate_store
assert "isBlockingSend" not in index_js
def test_welcome_composer_does_not_overlap_idle_progress_placeholder() -> None:
input_store = _read("webui/components/chat/input/input-store.js")
assert "!!chatsStore.selected &&\n ![\"all\", \"blocked\"].includes(this._getSendState())" in input_store
assert "!!chatsStore.selected &&\n this._getSendState() !== \"all\"" in input_store
def test_welcome_composer_buttons_keep_target_geometry_without_glow() -> None:

View file

@ -46,8 +46,7 @@
<div id="chat-buttons-wrapper">
<!-- Send button -->
<button class="chat-button" id="send-button" aria-label="Send message" @click="$store.chatInput.sendMessage()"
:class="$store.chatInput.sendButtonClass" :title="$store.chatInput.sendButtonTitle"
:disabled="$store.chatInput.sendDisabled">
:class="$store.chatInput.sendButtonClass" :title="$store.chatInput.sendButtonTitle">
<span class="material-symbols-outlined" x-text="$store.chatInput.sendButtonIcon"></span>
</button>
</div>
@ -388,14 +387,6 @@
filter: none;
}
#send-button.model-gate-blocked,
#send-button.model-gate-blocked:hover {
background-color: color-mix(in srgb, var(--color-border) 55%, transparent);
color: color-mix(in srgb, var(--color-text) 45%, transparent);
cursor: not-allowed;
opacity: 0.78;
}
#send-button:active {
background-color: #2b309c;
transform: translateY(1px) scale(0.98);

View file

@ -5,7 +5,6 @@ import { openLatest as openLatestSurface } from "/js/surfaces.js";
import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
import { store as modelGateStore } from "/components/chat/model-gate-store.js";
const ICON_MARKER_RE = /icon:\/\/([a-zA-Z0-9_]+)(\[(?:\\.|[^\]])*\])?/g;
const FENCE_LINE_RE = /^```([A-Za-z0-9_-]*)?$/;
@ -84,7 +83,6 @@ const model = {
const hasQueue = !!messageQueueStore?.hasQueue;
const running = !!chatsStore.selectedContext?.running;
if (modelGateStore?.isBlockingSend) return "blocked";
if (hasQueue && !hasInput) return "all";
if ((running || hasQueue) && hasInput) return "queue";
return "normal";
@ -93,7 +91,6 @@ const model = {
get inputPlaceholder() {
if (!chatsStore.selected) return "Ask anything to start a new chat";
const state = this._getSendState();
if (state === "blocked") return "Connect a model to send";
if (state === "all") return "Press Enter to send queued messages";
if (this.showProgressPlaceholder) return "";
return "Type your message here...";
@ -102,7 +99,7 @@ const model = {
get showProgressPlaceholder() {
return (
!!chatsStore.selected &&
!["all", "blocked"].includes(this._getSendState()) &&
this._getSendState() !== "all" &&
!!this.progressText &&
!this.message
);
@ -118,7 +115,6 @@ const model = {
// Computed: send button icon type
get sendButtonIcon() {
const state = this._getSendState();
if (state === "blocked") return "settings";
if (state === "all") return "send_and_archive";
if (state === "queue") return "schedule_send";
return "arrow_forward";
@ -127,7 +123,6 @@ const model = {
// Computed: send button CSS class
get sendButtonClass() {
const state = this._getSendState();
if (state === "blocked") return "model-gate-blocked";
if (state === "all") return "send-queue send-all";
if (state === "queue") return "send-queue queue";
return "";
@ -136,23 +131,17 @@ const model = {
// Computed: send button title
get sendButtonTitle() {
const state = this._getSendState();
if (state === "blocked") return "Connect a model to send";
if (state === "all") return "Send all queued messages";
if (state === "queue") return "Add to queue";
return "Send message";
},
get sendDisabled() {
return this._getSendState() === "blocked";
},
init() {
console.log("Input store initialized");
// Event listeners are now handled via Alpine directives in the component
},
async sendMessage() {
if (this.sendDisabled) return;
this._syncMessageFromEditor();
// Capture sent prompt to per-chat history (bash-style)

View file

@ -21,13 +21,6 @@ export const store = createStore("modelGate", {
dispatching: false,
_initialized: false,
get isBlockingSend() {
this.init();
if (!this.active || this.connected) return false;
const currentContext = globalThis.getContext?.();
return !this.pending?.context || !currentContext || this.pending.context === currentContext;
},
get introText() {
if (this.connected) {
return `Model connected: ${this.connectedLabel || "ready"}`;

View file

@ -42,8 +42,6 @@ let skipOneSpeech = false;
export async function sendMessage(options = {}) {
try {
if (!options.bypassModelGate && modelGateStore.isBlockingSend) return;
const hasProvidedMessage = Object.prototype.hasOwnProperty.call(options, "message");
let message = String(hasProvidedMessage ? options.message : inputStore.message).trim();
let attachmentsWithUrls = options.attachments || attachmentsStore.getAttachmentsForSending();