Note
Grok responding on behalf of Julius.
Triage
Thanks @rohitgirdhar for the precise report. I confirmed this on current main (31a9da179e). It's the same web composer behavior as #10684 and is still present. The merged #2829 closed #10684 and #10202 because V2 now stores the thread model on the server, but it didn't change how the desktop composer chooses which model to show and send.
What I verified
deriveEffectiveComposerModelState still prefers the draft entry for the selected instance and only falls back to threadModelSelection when that entry is missing:
| 1 | const instanceSelection = input.selectedInstanceId |
| 2 | ? input.draft?.modelSelectionByProvider?.[input.selectedInstanceId] |
| 3 | : undefined; |
| 4 | const activeSelection = instanceSelection ?? legacySelection; |
| 5 | const selectedModel = activeSelection?.model ? /* draft */ : baseModel; |
modelSelectionExplicit isn't an input, and modelSelection.test.ts still expects a custom-instance draft model to beat a different thread selection.
A thread created on desktop gets that draft entry from applyStickyState / setModelSelection. Promotion copies the whole draft onto the server-thread key (finalizePromotedDraftThread → removeDraftThreadReferences). clearComposerContent clears text and attachments but leaves modelSelectionByProvider and activeProvider, and persistence keeps model-only server-thread drafts. Nothing on thread.model-selection-updated or thread.provider-switched re-seeds or clears it.
resolveComposerProviderSelection then tries draft.activeProvider before the thread's instance. OpenCode advertises supportsProviderSwitchingViaHandoff, so the composer isn't locked to the thread's instance. Your two models share the opencode instance, so the seeded draft keeps winning. A desktop that never stored a selection for the thread would follow thread.modelSelection, but the usual path (creating the thread or picking a model there) does store one.
The next desktop send still writes the stale model back: startThreadTurn always sends modelSelection: ctxSelectedModelSelection, and if that differs from the thread, the orchestrator records a thread.model-selection-updated (or thread.provider-switched across instances). Since the picker already shows the old model, that reversal is invisible. Sidebar rows that read thread.modelSelection should update; the stale surface is the composer picker and the next send from it.
The same precedence also affects switches across providers whenever this client still has a draft for the old instance. Same-instance switches are just the case where the draft entry can never miss.
Related
- #10684 (closed) is this bug, closed by #2829's list on 2026-10-02 without a web composer change.
- #15190 (closed as a duplicate of #10684) is an earlier report of the same thing.
- #10202 (closed) is the mirror case (desktop changes, mobile stays stale). Open PR #10205 releases confirmed overrides on mobile and doesn't touch
apps/web.
Likely fix area
A maintainer will decide on the fix direction. Options in the same shape as #10205, on the web draft:
- After the server confirms this client's choice, drop or re-seed the thread-draft model so later sends follow
thread.modelSelection. - When
thread.model-selection-updatedorthread.provider-switchedchanges the thread model, re-seed unless this client made a newer explicit pick. - Keep a pick made here after the last confirmed send, including a same-value re-pick, and restore it if the send fails.
How to verify
Desktop A → mobile B on the same OpenCode instance → the desktop picker shows B and the next desktop send requests B. Also: an unsent desktop pick C survives a remote B, a failed send keeps C, and reopening the thread doesn't bring back a persisted A.