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 {...@@ -12,6 +12,7 @@ import {
12 resolveBranchToolbarValue,12 resolveBranchToolbarValue,
13 resolveLockedWorkspaceLabel,13 resolveLockedWorkspaceLabel,
14 shouldIncludeBranchPickerItem,14 shouldIncludeBranchPickerItem,
15 shouldShowEnvironmentIndicator,
15} from "./BranchToolbar.logic";16} from "./BranchToolbar.logic";
1617
17const localEnvironmentId = EnvironmentId.make("environment-local");18const localEnvironmentId = EnvironmentId.make("environment-local");
...@@ -119,6 +120,44 @@ describe("resolveEnvironmentOptionLabel", () => {...@@ -119,6 +120,44 @@ describe("resolveEnvironmentOptionLabel", () => {
119 });120 });
120});121});
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
122describe("resolveEffectiveEnvMode", () => {161describe("resolveEffectiveEnvMode", () => {
123 it("treats draft threads already attached to a worktree as current-checkout mode", () => {162 it("treats draft threads already attached to a worktree as current-checkout mode", () => {
124 expect(163 expect(
apps/web/src/components/BranchToolbar.logic.ts+11
...@@ -42,6 +42,17 @@ export function resolveEnvironmentOptionLabel(input: {...@@ -42,6 +42,17 @@ export function resolveEnvironmentOptionLabel(input: {
42 return runtimeLabel ?? savedLabel ?? input.environmentId;42 return runtimeLabel ?? savedLabel ?? input.environmentId;
43}43}
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
45export function resolveEnvModeLabel(mode: EnvMode): string {56export function resolveEnvModeLabel(mode: EnvMode): string {
46 return mode === "worktree" ? "New worktree" : "Current checkout";57 return mode === "worktree" ? "New worktree" : "Current checkout";
47}58}
apps/web/src/components/BranchToolbar.tsx+14-4
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 resolveEnvModeLabel,20 resolveEnvModeLabel,
21 resolveEffectiveEnvMode,21 resolveEffectiveEnvMode,
22 resolveLockedWorkspaceLabel,22 resolveLockedWorkspaceLabel,
23 shouldShowEnvironmentIndicator,
23} from "./BranchToolbar.logic";24} from "./BranchToolbar.logic";
24import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";25import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";
25import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";26import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";
...@@ -60,6 +61,7 @@ interface MobileRunContextSelectorProps {...@@ -60,6 +61,7 @@ interface MobileRunContextSelectorProps {
60 environmentId: EnvironmentId;61 environmentId: EnvironmentId;
61 availableEnvironments: readonly EnvironmentOption[] | undefined;62 availableEnvironments: readonly EnvironmentOption[] | undefined;
62 showEnvironmentPicker: boolean;63 showEnvironmentPicker: boolean;
64 showEnvironmentIndicator: boolean;
63 onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined;65 onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined;
64 effectiveEnvMode: EnvMode;66 effectiveEnvMode: EnvMode;
65 activeWorktreePath: string | null;67 activeWorktreePath: string | null;
...@@ -72,6 +74,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -72,6 +74,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
72 environmentId,74 environmentId,
73 availableEnvironments,75 availableEnvironments,
74 showEnvironmentPicker,76 showEnvironmentPicker,
77 showEnvironmentIndicator,
75 onEnvironmentChange,78 onEnvironmentChange,
76 effectiveEnvMode,79 effectiveEnvMode,
77 activeWorktreePath,80 activeWorktreePath,
...@@ -94,7 +97,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -94,7 +97,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
94 : resolveCurrentWorkspaceLabel(activeWorktreePath);97 : resolveCurrentWorkspaceLabel(activeWorktreePath);
95 const isLocked = envLocked || envModeLocked;98 const isLocked = envLocked || envModeLocked;
96 const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon;99 const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon;
97 const icon = showEnvironmentPicker ? (100 const icon = showEnvironmentIndicator ? (
98 // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px101 // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px
99 // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px.102 // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px.
100 <span className="inline-flex shrink-0 items-center gap-0.5">103 <span className="inline-flex shrink-0 items-center gap-0.5">
...@@ -108,7 +111,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -108,7 +111,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
108 <>111 <>
109 {icon}112 {icon}
110 <span className="min-w-0 truncate">113 <span className="min-w-0 truncate">
111 {showEnvironmentPicker ? (activeEnvironment?.label ?? "Run on") : workspaceLabel}114 {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel}
112 </span>115 </span>
113 </>116 </>
114 );117 );
...@@ -234,6 +237,12 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -234,6 +237,12 @@ export const BranchToolbar = memo(function BranchToolbar({
234 const showEnvironmentPicker = Boolean(237 const showEnvironmentPicker = Boolean(
235 availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange,238 availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange,
236 );239 );
240 const activeEnvironmentOption =
241 availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null;
242 const showEnvironmentIndicator = shouldShowEnvironmentIndicator({
243 activeEnvironment: activeEnvironmentOption,
244 canPickEnvironment: showEnvironmentPicker,
245 });
237 const isMobile = useIsMobile();246 const isMobile = useIsMobile();
238247
239 if (!hasActiveThread || !activeProject) return null;248 if (!hasActiveThread || !activeProject) return null;
...@@ -247,6 +256,7 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -247,6 +256,7 @@ export const BranchToolbar = memo(function BranchToolbar({
247 environmentId={environmentId}256 environmentId={environmentId}
248 availableEnvironments={availableEnvironments}257 availableEnvironments={availableEnvironments}
249 showEnvironmentPicker={showEnvironmentPicker}258 showEnvironmentPicker={showEnvironmentPicker}
259 showEnvironmentIndicator={showEnvironmentIndicator}
250 onEnvironmentChange={onEnvironmentChange}260 onEnvironmentChange={onEnvironmentChange}
251 effectiveEnvMode={effectiveEnvMode}261 effectiveEnvMode={effectiveEnvMode}
252 activeWorktreePath={activeWorktreePath}262 activeWorktreePath={activeWorktreePath}
...@@ -254,13 +264,13 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -254,13 +264,13 @@ export const BranchToolbar = memo(function BranchToolbar({
254 />264 />
255 ) : (265 ) : (
256 <div className="flex min-w-0 shrink-0 items-center gap-1">266 <div className="flex min-w-0 shrink-0 items-center gap-1">
257 {showEnvironmentPicker && availableEnvironments && onEnvironmentChange && (267 {showEnvironmentIndicator && availableEnvironments && (
258 <>268 <>
259 <BranchToolbarEnvironmentSelector269 <BranchToolbarEnvironmentSelector
260 envLocked={envLocked}270 envLocked={envLocked}
261 environmentId={environmentId}271 environmentId={environmentId}
262 availableEnvironments={availableEnvironments}272 availableEnvironments={availableEnvironments}
263 onEnvironmentChange={onEnvironmentChange}273 {...(showEnvironmentPicker && onEnvironmentChange ? { onEnvironmentChange } : {})}
264 />274 />
265 <Separator orientation="vertical" className="mx-0.5 h-3.5!" />275 <Separator orientation="vertical" className="mx-0.5 h-3.5!" />
266 </>276 </>
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+4-2
...@@ -17,7 +17,9 @@ interface BranchToolbarEnvironmentSelectorProps {...@@ -17,7 +17,9 @@ interface BranchToolbarEnvironmentSelectorProps {
17 envLocked: boolean;17 envLocked: boolean;
18 environmentId: EnvironmentId;18 environmentId: EnvironmentId;
19 availableEnvironments: readonly EnvironmentOption[];19 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;
21}23}
2224
23export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({25export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({
...@@ -39,7 +41,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -39,7 +41,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
39 [availableEnvironments],41 [availableEnvironments],
40 );42 );
4143
42 if (envLocked) {44 if (envLocked || onEnvironmentChange === undefined) {
43 return (45 return (
44 <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">46 <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">
45 {activeEnvironment?.isPrimary ? (47 {activeEnvironment?.isPrimary ? (