authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-20 18:27:57-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 18:27:57-07:00
logc0bb2373450231e3931937d2f703e35ce906ed85
tree8c58f56b6313ec6debdee9df563d02255f02ff46
parent5d34f9ff235115d43a6cb4b4561d10badf218b87
signature Signed by PGP key B5690EEEBB952194

fix(web): always show environment chip for remote projects (#4217)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

4 files changed, 68 insertions(+), 6 deletions(-)

apps/web/src/components/BranchToolbar.logic.test.ts+39
......@@ -12,6 +12,7 @@ import {
1212 resolveBranchToolbarValue,
1313 resolveLockedWorkspaceLabel,
1414 shouldIncludeBranchPickerItem,
15 shouldShowEnvironmentIndicator,
1516} from "./BranchToolbar.logic";
1617
1718const localEnvironmentId = EnvironmentId.make("environment-local");
......@@ -119,6 +120,44 @@ describe("resolveEnvironmentOptionLabel", () => {
119120 });
120121});
121122
123describe("shouldShowEnvironmentIndicator", () => {
124 it("shows the indicator whenever multiple environments are pickable", () => {
125 expect(
126 shouldShowEnvironmentIndicator({
127 activeEnvironment: { isPrimary: true },
128 canPickEnvironment: true,
129 }),
130 ).toBe(true);
131 });
132
133 it("shows a sole remote environment so the user knows where the project runs", () => {
134 expect(
135 shouldShowEnvironmentIndicator({
136 activeEnvironment: { isPrimary: false },
137 canPickEnvironment: false,
138 }),
139 ).toBe(true);
140 });
141
142 it("hides a sole primary (this-device) environment", () => {
143 expect(
144 shouldShowEnvironmentIndicator({
145 activeEnvironment: { isPrimary: true },
146 canPickEnvironment: false,
147 }),
148 ).toBe(false);
149 });
150
151 it("hides the indicator when the active environment is unknown", () => {
152 expect(
153 shouldShowEnvironmentIndicator({
154 activeEnvironment: null,
155 canPickEnvironment: false,
156 }),
157 ).toBe(false);
158 });
159});
160
122161describe("resolveEffectiveEnvMode", () => {
123162 it("treats draft threads already attached to a worktree as current-checkout mode", () => {
124163 expect(
apps/web/src/components/BranchToolbar.logic.ts+11
......@@ -42,6 +42,17 @@ export function resolveEnvironmentOptionLabel(input: {
4242 return runtimeLabel ?? savedLabel ?? input.environmentId;
4343}
4444
45// A remote (non-primary) environment is always surfaced, even when it is the
46// only environment available: with a single connected machine there is nothing
47// to pick, but the user still needs to see where the project runs.
48export function shouldShowEnvironmentIndicator(input: {
49 activeEnvironment: Pick<EnvironmentOption, "isPrimary"> | null;
50 canPickEnvironment: boolean;
51}): boolean {
52 if (input.canPickEnvironment) return true;
53 return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary;
54}
55
4556export function resolveEnvModeLabel(mode: EnvMode): string {
4657 return mode === "worktree" ? "New worktree" : "Current checkout";
4758}
apps/web/src/components/BranchToolbar.tsx+14-4
......@@ -20,6 +20,7 @@ import {
2020 resolveEnvModeLabel,
2121 resolveEffectiveEnvMode,
2222 resolveLockedWorkspaceLabel,
23 shouldShowEnvironmentIndicator,
2324} from "./BranchToolbar.logic";
2425import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";
2526import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";
......@@ -60,6 +61,7 @@ interface MobileRunContextSelectorProps {
6061 environmentId: EnvironmentId;
6162 availableEnvironments: readonly EnvironmentOption[] | undefined;
6263 showEnvironmentPicker: boolean;
64 showEnvironmentIndicator: boolean;
6365 onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined;
6466 effectiveEnvMode: EnvMode;
6567 activeWorktreePath: string | null;
......@@ -72,6 +74,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
7274 environmentId,
7375 availableEnvironments,
7476 showEnvironmentPicker,
77 showEnvironmentIndicator,
7578 onEnvironmentChange,
7679 effectiveEnvMode,
7780 activeWorktreePath,
......@@ -94,7 +97,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
9497 : resolveCurrentWorkspaceLabel(activeWorktreePath);
9598 const isLocked = envLocked || envModeLocked;
9699 const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon;
97 const icon = showEnvironmentPicker ? (
100 const icon = showEnvironmentIndicator ? (
98101 // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px
99102 // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px.
100103 <span className="inline-flex shrink-0 items-center gap-0.5">
......@@ -108,7 +111,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
108111 <>
109112 {icon}
110113 <span className="min-w-0 truncate">
111 {showEnvironmentPicker ? (activeEnvironment?.label ?? "Run on") : workspaceLabel}
114 {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel}
112115 </span>
113116 </>
114117 );
......@@ -234,6 +237,12 @@ export const BranchToolbar = memo(function BranchToolbar({
234237 const showEnvironmentPicker = Boolean(
235238 availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange,
236239 );
240 const activeEnvironmentOption =
241 availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null;
242 const showEnvironmentIndicator = shouldShowEnvironmentIndicator({
243 activeEnvironment: activeEnvironmentOption,
244 canPickEnvironment: showEnvironmentPicker,
245 });
237246 const isMobile = useIsMobile();
238247
239248 if (!hasActiveThread || !activeProject) return null;
......@@ -247,6 +256,7 @@ export const BranchToolbar = memo(function BranchToolbar({
247256 environmentId={environmentId}
248257 availableEnvironments={availableEnvironments}
249258 showEnvironmentPicker={showEnvironmentPicker}
259 showEnvironmentIndicator={showEnvironmentIndicator}
250260 onEnvironmentChange={onEnvironmentChange}
251261 effectiveEnvMode={effectiveEnvMode}
252262 activeWorktreePath={activeWorktreePath}
......@@ -254,13 +264,13 @@ export const BranchToolbar = memo(function BranchToolbar({
254264 />
255265 ) : (
256266 <div className="flex min-w-0 shrink-0 items-center gap-1">
257 {showEnvironmentPicker && availableEnvironments && onEnvironmentChange && (
267 {showEnvironmentIndicator && availableEnvironments && (
258268 <>
259269 <BranchToolbarEnvironmentSelector
260270 envLocked={envLocked}
261271 environmentId={environmentId}
262272 availableEnvironments={availableEnvironments}
263 onEnvironmentChange={onEnvironmentChange}
273 {...(showEnvironmentPicker && onEnvironmentChange ? { onEnvironmentChange } : {})}
264274 />
265275 <Separator orientation="vertical" className="mx-0.5 h-3.5!" />
266276 </>
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+4-2
......@@ -17,7 +17,9 @@ interface BranchToolbarEnvironmentSelectorProps {
1717 envLocked: boolean;
1818 environmentId: EnvironmentId;
1919 availableEnvironments: readonly EnvironmentOption[];
20 onEnvironmentChange: (environmentId: EnvironmentId) => void;
20 // Absent when there is only one environment to show: the indicator still
21 // renders (as a static label) so remote projects are always identifiable.
22 onEnvironmentChange?: (environmentId: EnvironmentId) => void;
2123}
2224
2325export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({
......@@ -39,7 +41,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
3941 [availableEnvironments],
4042 );
4143
42 if (envLocked) {
44 if (envLocked || onEnvironmentChange === undefined) {
4345 return (
4446 <span className="inline-flex items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:text-xs">
4547 {activeEnvironment?.isPrimary ? (