Before submitting
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
loadBalancingEnabled on.
- 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 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.
Before submitting
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.Steps to reproduce
loadBalancingEnabledon.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:Only two places write
environmentSelection: "auto"back, and both are gated behindautomaticEnvironmentitself: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) writesenvironmentSelection: "manual"and does not clearbranch. 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: withbranchset andenvironmentSelectionanything other than"auto", the(!draftThread?.branch || ...)term is false, andonAutoEnvironment- the only thing that would clearbranchback tonull- is unreachable for the same reason.Because
openOrReuseProjectDraftThread(ChatView.tsx:2494) resolves drafts throughgetDraftSessionByLogicalProjectKey, the dead state is scoped to the logical project and is inherited by every new thread there.Observed directly in
t3code:composer-drafts:v1on a 5-environment install, two projects,loadBalancingEnabled: true:github.com/<org>/aautomaingithub.com/<org>/bmanualmainnullThe entry gives no signal that it is dead
BranchToolbarEnvironmentSelector.tsxrenders the item offonAutoEnvironmentbut guards the click onautoEnvironmentLabel:onAutoEnvironmentis passed on a four-term check (ChatView.tsx:10207) that does not include any of theautomaticEnvironmentterms:The
?? "Auto balance"fallback on L146 (and the identical one inenvironmentItems, L46) makes the dead state pixel-identical to the live one.Two smaller things in the same file, same root cause:
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:
Possible fix
The rendering/click mismatch and the unrecoverable state are separable.
1. Make the transition two-way.
onAutoEnvironmentalready 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-termonAutoEnvironmentcondition rather than onautoEnvironmentLabel, i.e. drop theif (autoEnvironmentLabel)guard on L141 and letonAutoEnvironmentdecide for itself (it already early-returns onenvLocked/!draftIdand already toasts for the attachment case). That alone restores recovery.2. Stop the entry from lying about its state. Either hide it when inert:
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
autoEnvironmentLabelisundefinedre-closes the loop and also makes the "Keep attachments on this machine" toast unreachable. Fixing (1) by dropping the L141 guard and handling (2) withdisabled+ 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.