Skip to content

[Bug]: Desktop keeps the old model after the same thread changes on mobile #10684

Description

@satyalyadav

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/web

Steps to reproduce

  1. Start a new thread on desktop with model A, either picked in the composer or seeded from sticky/project defaults.
  2. Send a message. The thread runs on A.
  3. Switch the same thread's model to B from the mobile app and send a message there. The server records the change and the phone shows B.
  4. Return to the desktop app and open the same thread.
  5. The composer still shows A, even though the thread is running on B.
  6. Send another message from desktop.

Expected behavior

The desktop composer follows the thread's server-side model after another client changes it. A newer explicit pick made on that client should override it.

Actual behavior

The composer keeps showing A. Step 6 is worse than a stale label: the desktop send path syncs the composer's model back into the thread, so the thread's model silently reverts to A and the turn runs on A. The UI never changes (it was already showing A), so the reversal is invisible to the user.

Evidence from the affected thread

Thread 9954b2b6-9772-43f0-a447-94863a11b15f (OpenCode provider, Linux desktop + mobile). Read-only check of the local server DB, 2026-09-08 UTC:

Time Event Model
03:05:30 thread.created nvidia/moonshotai/kimi-k3 (variant max)
04:29:36 thread.meta-updated, then turn-start-requested opencode/muse-spark-1.3-contributor-free (variant xhigh)
04:30:10 thread.meta-updated, then turn-start-requested asu/glm-5-3-flash

Both model changes came from the phone, 34 seconds apart. The projection's model_selection_json points at asu/glm-5-3-flash and later turns use it. The desktop composer kept showing the original model for the rest of the session.

Where the behavior lives in the code

  • deriveEffectiveComposerModelState (apps/web/src/composerDraftStore.ts) prefers the draft's stored modelSelectionByProvider over threadModelSelection for both the displayed model and the sent model. The modelSelectionExplicit flag does not gate this precedence.
  • For an existing thread the draft is seeded once at thread creation (applyStickyState / setModelSelection in useHandleNewThread) and the seed survives draft promotion. Nothing re-seeds or clears the stored draft selection when thread.meta-updated arrives with a different model. The thread reducer does update thread.modelSelection from that event, but the composer never consults it once the draft holds a stored selection.
  • The send path's persistThreadSettingsForNextTurn (ChatView) compares the composer model with serverThread.modelSelection and issues thread.meta.update when they differ. That is the silent-reversal mechanism.

Impact

Major degradation or frequent failure. The UI lies about which model is running, and the next send from the stale client flips the thread's model with no visible signal.

Version or commit

Current desktop and mobile releases, observed 2026-09-08. Code path verified against current main.

Environment

Linux, T3 Code desktop app and mobile app, OpenCode provider.

Related work

Suggested fix

Treat a stored draft model for an existing thread the way #10205 treats the mobile one: an override that is released once the server confirms its use. When thread.meta-updated changes the thread model, re-seed the stored draft selection unless this client made a newer explicit pick. Keep a pick made on this client after the last confirmed send, and restore it if a send fails.

Activity

  1. juliusmarminge commented on Sep 8, 2026

    @juliusmarminge
    Member

    Triage

    Confirmed as a real web composer-state bug. Complementary to #10202, not a duplicate of #10202, #6508, or #5421. PR #10205 is mobile-only and does not cover this direction.

    What we verified

    On current main, the desktop composer prefers a persisted thread-draft model over the server thread model for both display and the next send. deriveEffectiveComposerModelState resolves the draft map first and only falls back to threadModelSelection when that map has no entry for the selected instance:

    const instanceSelection = input.selectedInstanceId
      ? input.draft?.modelSelectionByProvider?.[input.selectedInstanceId]
      : undefined;
    const activeSelection = instanceSelection ?? legacySelection;
    const selectedModel = activeSelection?.model ? /* draft */ : baseModel;

    modelSelectionExplicit is not an input and does not gate this precedence. Existing tests document the draft winning over a different thread model (modelSelection.test.ts, custom-instance draft vs thread selection).

    For a thread created on desktop, that stored selection is seeded once in useHandleNewThread (applyStickyState / setModelSelection without explicit: true). Promotion copies the whole draft onto the server-thread key (finalizePromotedDraftThread → removeDraftThreadReferences). After send, clearComposerContent keeps modelSelectionByProvider. Persistence keeps model-only server-thread drafts; stripLegacyModelSeedsFromEmptyDraftSessions only strips empty draft sessions, and the migration test explicitly retains a server-thread seed.

    thread.meta-updated does update thread.modelSelection in threadReducer. Nothing re-seeds or clears the stored draft selection, so the composer never follows the remote change.

    The send path then syncs the stale composer model back. persistThreadSettingsForNextTurn calls resolveThreadMetadataUpdateForNextTurn, which issues thread.meta.update whenever the composer selection differs from serverThread.modelSelection. That is the silent-reversal mechanism: the UI already shows A, so writing A back is invisible.

    The reported thread (9954b2b6-9772-43f0-a447-94863a11b15f) is consistent with this path: mobile wrote two later models; the projection kept the last one; desktop stayed on the original seed.

    Related work (not duplicates)

    Web already has modelSelectionExplicit for new-thread reseeding. That flag is not enough here: seeded selections still win in deriveEffectiveComposerModelState, confirmed picks survive clearComposerContent, and no handler consumes thread.meta-updated.

    Suggested fix

    Treat a stored draft model on an existing thread as an unsent override, in the same spirit as #10205:

    1. After this client’s choice is confirmed by the server, drop or re-seed the thread-draft model so later compose/send follow thread.modelSelection.
    2. When thread.meta-updated (or thread.turn-start-requested) changes the thread model, re-seed the stored draft unless this client made a newer explicit pick.
    3. Keep a pick made on this client after the last confirmed send, including a same-value re-pick.
    4. Restore the draft model on failed send.

    The existing modelSelectionExplicit flag is a useful hook, but the fix also needs a release-after-confirm (or equivalent pick identity) plus a remote-meta update path. Do not wait on #10205.

    Verification

    Need desktop A → mobile B → desktop on the same thread, checking both the picker and the next requested model. Also cover: unsent desktop pick C that must survive a remote B; failed send; empty vs non-empty draft; restart so a persisted seed cannot resurrect A. Provider: OpenCode. Linux desktop + mobile.

    Labels

    bug, accepted, via-triage (remove needs-triage)

  2. added
    via-triageFiled through npx t3 triage
    acceptedfeature request accepted
    bugSomething is broken or behaving incorrectly.
    on Sep 8, 2026
  3. added a commit that references this issue on Sep 8, 2026
    27ae3cb
  4. rohitgirdhar commented on Oct 3, 2026

    @rohitgirdhar

    Correction: the "responding as human here" line in my previous comment was a mistake by the triage agent — I am an agent filing on behalf of @rohitgirdhar, not the human user. Sorry for the confusion.

    To keep this clean I opened a focused regression task with the unfixed scope (same-instance opencode model switch, code refs, version numbers): #15195. Please follow that one; this comment thread can be ignored/closed as duplicate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    acceptedfeature request acceptedbugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions