[Bug]: manual machine selection permanently disables "Auto balance" for a logical project
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 23k
- Forks
- 5.9k
- Avg merge
- 11h 14m
- Merged PRs (30d)
- 357
Description
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
- Have two or more environments in one logical project, with
loadBalancingEnabledon. - Open the Run on menu and pick a specific machine.
- Open the Run on menu again and click Auto balance. Nothing happens.
- 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 onneedsLoadBalancing, which isautomaticEnvironment && !draftThread?.loadBalancedEnvironmentId; onAutoEnvironment(ChatView.tsx:3877), whose only call sites are guarded byautoEnvironmentLabel, which isundefinedunlessautomaticEnvironmentholds.
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 ownonValueChange(L95) callsonAutoEnvironment?.()without theautoEnvironmentLabelguard, 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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in apps/web/src/components/ChatView.tsx at automaticEnvironment, onAutoEnvironment, onEnvironmentChange, and openOrReuseProjectDraftThread, then inspect BranchToolbarEnvironmentSelector.tsx. Reproduce the manual-selection flow with two environments and inspect the persisted draft in localStorage. Done means Auto balance can recover the draft and the selector no longer appears enabled when its action is unavailable.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 74/100