Skip to content

[Bug]: manual machine selection permanently disables "Auto balance" for a logical project #12688

Description

@shunkakinoki

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

Summary

Picking a machine by hand from the composer's Run on menu permanently disables automatic routing for that logical project. The "Auto balance" entry keeps rendering and stays visually identical to a working one, but clicking it is a no-op, and there is no in-app way to get back to automatic routing.

The state is persisted per logical project in localStorage (t3code:composer-drafts:v1), so it survives reloads and every subsequent new thread in that project.

Edited after filing: the original report blamed composer attachments. That is one way to hit the inert entry, but it is not the important one - the attachment case is recoverable by removing the attachment. The manual-selection case is not recoverable at all, and that is the real defect. Steps, workaround and suggested fix below are updated accordingly.

Steps to reproduce

  1. Have two or more environments in one logical project, with loadBalancingEnabled on.
  2. Open the Run on menu and pick a specific machine.
  3. Open the Run on menu again and click Auto balance. Nothing happens.
  4. Start a new thread in the same project. Still nothing - the draft is reused by logical project key.

Expected behavior

Selecting Auto balance after a manual pick returns the draft to automatic routing. Failing that, the entry communicates that it cannot - disabled, hidden, or a distinct label. The existing "Auto balance unavailable" / "Checking machines…" labels already establish that pattern.

Actual behavior

automaticEnvironment (apps/web/src/components/ChatView.tsx:2622) requires, among other terms:

draftThread?.environmentSelection !== "manual" &&
(!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) &&
(!draftThread?.branch || draftThread.environmentSelection === "auto") &&
!draftThread?.worktreePath

Only two places write environmentSelection: "auto" back, and both are gated behind automaticEnvironment itself:

  • the load-balancing useEffect (ChatView.tsx:3850), gated on needsLoadBalancing, which is automaticEnvironment && !draftThread?.loadBalancedEnvironmentId;
  • onAutoEnvironment (ChatView.tsx:3877), whose only call sites are guarded by autoEnvironmentLabel, which is undefined unless automaticEnvironment holds.

Meanwhile onEnvironmentChange (ChatView.tsx:3912) writes environmentSelection: "manual" and does not clear branch. So a single manual pick clears the flag that both recovery paths require. It is a one-way transition.

The same closed loop applies to branch: with branch set and environmentSelection anything other than "auto", the (!draftThread?.branch || ...) term is false, and onAutoEnvironment - the only thing that would clear branch back to null - is unreachable for the same reason.

Because openOrReuseProjectDraftThread (ChatView.tsx:2494) resolves drafts through getDraftSessionByLogicalProjectKey, the dead state is scoped to the logical project and is inherited by every new thread there.

Observed directly in t3code:composer-drafts:v1 on a 5-environment install, two projects, loadBalancingEnabled: true:

logical project environmentSelection branch loadBalancedEnvironmentId Auto balance
github.com/<org>/a auto main set works
github.com/<org>/b manual main null dead

The entry gives no signal that it is dead

BranchToolbarEnvironmentSelector.tsx renders the item off onAutoEnvironment but guards the click on autoEnvironmentLabel:

{onAutoEnvironment && (
  <SelectItem
    value="auto"
    onClick={() => {
      if (autoEnvironmentLabel) onAutoEnvironment?.();   // L141 - no-op when undefined
    }}
  >
    <span className="inline-flex items-center gap-1.5">
      <ScaleIcon className="size-3" aria-hidden="true" />
      {autoEnvironmentLabel ?? "Auto balance"}            {/* L146 - looks enabled */}
    </span>
  </SelectItem>
)}

onAutoEnvironment is passed on a four-term check (ChatView.tsx:10207) that does not include any of the automaticEnvironment terms:

onAutoEnvironment={
  draftId && !envLocked && hasMultipleEnvironments && loadBalancingSettings.loadBalancingEnabled
    ? onAutoEnvironment
    : undefined
}

The ?? "Auto balance" fallback on L146 (and the identical one in environmentItems, L46) makes the dead state pixel-identical to the live one.

Two smaller things in the same file, same root cause:

  • The Select's own onValueChange (L95) calls onAutoEnvironment?.() without the autoEnvironmentLabel guard, so the two activation paths in one component disagree about whether the entry is live.
  • value={autoEnvironmentLabel ? "auto" : environmentId} (L93) means the entry can never show as selected in the inert state.

Impact

Minor bug or occasional failure

Version or commit

main @ 7445aa7 (also reproduces on the published npm build, t3 v0.0.42)

Environment

macOS 27.0 (Darwin), t3 v0.0.42, 5 linked environments

Workaround

There is no in-app recovery. The persisted draft has to be edited directly, from the renderer console:

const k = 't3code:composer-drafts:v1';
const s = JSON.parse(localStorage.getItem(k));
const tk = s.state.logicalProjectDraftThreadKeyByLogicalProjectKey['github.com/<org>/<repo>'];
Object.assign(s.state.draftThreadsByThreadKey[tk], {
  environmentSelection: 'auto', branch: null, worktreePath: null, loadBalancedEnvironmentId: null,
});
localStorage.setItem(k, JSON.stringify(s));
location.reload();

Possible fix

The rendering/click mismatch and the unrecoverable state are separable.

1. Make the transition two-way. onAutoEnvironment already sets exactly the right thing - { environmentSelection: "auto", loadBalancedEnvironmentId: null, branch: null, worktreePath: null }. It just has to be reachable. Gate the click on the four-term onAutoEnvironment condition rather than on autoEnvironmentLabel, i.e. drop the if (autoEnvironmentLabel) guard on L141 and let onAutoEnvironment decide for itself (it already early-returns on envLocked/!draftId and already toasts for the attachment case). That alone restores recovery.

2. Stop the entry from lying about its state. Either hide it when inert:

-      ...(onAutoEnvironment
+      ...(onAutoEnvironment && autoEnvironmentLabel
         ? [{ value: "auto", label: autoEnvironmentLabel ?? "Auto balance" }]
         : []),

or keep it clickable and give it a distinct label, matching the existing "Auto balance unavailable" treatment.

Note that (1) and the hide-it variant of (2) conflict - hiding the entry whenever autoEnvironmentLabel is undefined re-closes the loop and also makes the "Keep attachments on this machine" toast unreachable. Fixing (1) by dropping the L141 guard and handling (2) with disabled + a distinct label only for the genuinely unrecoverable states seems like the consistent combination, but it depends on what the toast is meant to be for. Happy to open a PR for whichever direction you prefer.

Activity

  1. added
    bugSomething is broken or behaving incorrectly.
    acceptedfeature request accepted
    via-triageFiled through npx t3 triage
    on Sep 20, 2026
  2. juliusmarminge commented on Sep 20, 2026

    @juliusmarminge
    Member

    Triage

    Confirmed from source on main @ 7445aa7. Not a duplicate.

    Run on can show a live-looking Auto balance row after the draft has left automatic routing. The click path is a no-op in that gap, and the label is the same as the working state.

    Where

    onAutoEnvironment is passed when the draft can choose a host (draftId && !envLocked && hasMultipleEnvironments && loadBalancingEnabled) in ChatView.tsx.

    autoEnvironmentLabel is only set while automaticEnvironment is true. That adds: not manual, no unassigned attachments, no pinned branch (unless already auto), no worktreePath.

    The menus render the row off onAutoEnvironment and guard onClick on autoEnvironmentLabel, with {autoEnvironmentLabel ?? "Auto balance"}:

    Same control via composer.host. Desktop uses this web UI. Native mobile does not auto-balance.

    autoEnvironmentLabel means “currently auto,” not “auto is offered.” After a manual host / branch / worktree pin the label is unset — that is when the row must stay so the user can switch back.

    Suggested fix

    Do not hide the row when autoEnvironmentLabel is unset. That would remove the way back to auto.

    Drop the if (autoEnvironmentLabel) guard so both onClick and onValueChange call onAutoEnvironment. That handler already toasts (“Keep attachments on this machine”) and clears branch/worktree.

    onValueChange is already unguarded, so a machine → "auto" change should still run the handler. If a click is a true no-op, the custom onClick may be swallowing the select/radio change. Unify the paths in both menus either way.

    Optional: a distinct unavailable label when attachments block routing, matching "Auto balance unavailable".

    Severity

    Minor UX. Workaround: remove attachments or start a new thread. No extra info needed.

    Related: #12684 / #12685 (same menu, stays open), #9895 (this split), #10407 (unavailable label), #12481 (same props on the workspace host menu).

  3. changed the title [-][Bug]: "Auto balance" menu entry renders but is a no-op when automaticEnvironment is false[/-] [+][Bug]: manual machine selection permanently disables "Auto balance" for a logical project[/+] on Sep 20, 2026
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