The model selector that forgot what you picked
The brainstem has a model dropdown in its header. You pick claude-opus-4-7-1m from the Copilot catalog. The chat works. You refresh the page. The dropdown is back to gpt-4o. You sigh, pick Opus again, send another message. Refresh. gpt-4o again.
This bug lived for about a week before someone (me) noticed it was a bug and not just “Claude is the wrong default.”
The cause was a race between two functions, both running at boot:
function loadSettings() {
const saved = LS.get('settings', null);
if (saved) Object.assign(state.settings, saved); // restores model = 'claude-opus-4-7-1m'
// ... more setup ...
refreshModelOptions(); // ← this is the bug
}
function refreshModelOptions() {
const sel = $('#model-sel');
sel.innerHTML = '';
for (const m of PROVIDERS[state.settings.provider].models) {
const o = document.createElement('option');
o.value = m.id; o.textContent = m.name;
sel.appendChild(o);
}
// The "fix it back to default" line:
if (!PROVIDERS[state.settings.provider].models.find(m => m.id === state.settings.model)) {
state.settings.model = PROVIDERS[state.settings.provider].defaultModel; // bug
}
sel.value = state.settings.model;
}
The flow:
loadSettings()restoresstate.settings.model = 'claude-opus-4-7-1m'from localStorage. ✓refreshModelOptions()runs synchronously with the staticPROVIDERS.copilot.modelslist — which only has a few hardcoded defaults likegpt-4o. The full Copilot catalog (138 models) is fetched asynchronously and hasn’t arrived yet.- The check
if (!PROVIDERS[provider].models.find(m => m.id === state.settings.model))is true — Opus isn’t in the static list yet. - Bug: the function overwrites
state.settings.modelto the static defaultgpt-4o. - Asynchronously,
fetchCopilotModels()returns with the full catalog. It callsrefreshModelOptions()again. - Now Opus is in the list, but
state.settings.modelis alreadygpt-4o(from step 4). The dropdown selectsgpt-4o.
The user picked Opus. The dropdown shows GPT. The model used for the next chat is GPT.
The fix:
Don’t overwrite state.settings.model in refreshModelOptions. If the saved model isn’t in the current option list, append it as a placeholder so the dropdown still reflects the user’s pick:
function refreshModelOptions() {
const sel = $('#model-sel');
const provider = PROVIDERS[state.settings.provider];
sel.innerHTML = '';
for (const m of provider.models) {
const o = document.createElement('option');
o.value = m.id; o.textContent = m.name;
sel.appendChild(o);
}
// Preserve the user's saved choice even when the static defaults don't
// include it yet. The async catalog fetch will repopulate shortly.
const saved = state.settings.model;
if (saved && !provider.models.find(m => m.id === saved)) {
const o = document.createElement('option');
o.value = saved; o.textContent = `${saved} (loading…)`;
sel.appendChild(o);
}
sel.value = saved || provider.defaultModel;
}
The dropdown briefly shows claude-opus-4-7-1m (loading…) while the Copilot catalog fetches. When the catalog arrives, the next refreshModelOptions() call replaces the option list with proper labels — the user’s choice stays selected the whole time.
Why this took a week to find:
The bug only reproduced when:
- Provider is Copilot (which is the default).
- The user had picked a model not in the small static defaults list (i.e., anything except
gpt-4o). - The user had refreshed the page since picking.
If you only ever used gpt-4o you never saw it. If you didn’t refresh between picking and using, you never saw it. That’s a small slice of users hitting a 100% reproducible bug, which felt like “something flaky” until I sat down with it.
Two lessons:
One: be suspicious of code that overwrites user state. Any time you see state.settings.x = default in code that’s not explicitly the “reset to defaults” path, ask why. The model setter is “use the user’s choice”; defaults are for when there’s no user choice. Don’t blur them.
Two: race conditions between sync and async are real even in a single-threaded JS runtime. Sync code runs to completion before any async callback fires. That’s exactly the problem here — refreshModelOptions ran synchronously, reset the model, then the async fetchCopilotModels arrived too late. You can’t outrun the sync code from an async context. Design for the order of events, don’t hope for it.
The fix shipped as commit 62beb20 — about ten lines added and removed. The dropdown now remembers what you picked, every time.