From cedea8242765a8c0bcd397cbfb046c98388545ba Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 20 Jul 2026 18:05:36 -0700 Subject: [PATCH] fix(web): always show environment chip for remote projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The environment ("Run on") chip below the composer only rendered when there was more than one environment to pick from. With a single remote machine connected on the hosted web app, nothing indicated which machine the project runs on. Show the chip whenever the active environment is non-primary, even when it is the only one — rendered as a static label when there is no choice to make. A sole local (this-device) environment stays hidden as before. Co-Authored-By: Claude Fable 5 --- .../components/BranchToolbar.logic.test.ts | 39 +++++++++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 11 ++++++ apps/web/src/components/BranchToolbar.tsx | 18 +++++++-- .../BranchToolbarEnvironmentSelector.tsx | 6 ++- 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 94a3909a961..8291e5e006e 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -12,6 +12,7 @@ import { resolveBranchToolbarValue, resolveLockedWorkspaceLabel, shouldIncludeBranchPickerItem, + shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -119,6 +120,44 @@ describe("resolveEnvironmentOptionLabel", () => { }); }); +describe("shouldShowEnvironmentIndicator", () => { + it("shows the indicator whenever multiple environments are pickable", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: { isPrimary: true }, + canPickEnvironment: true, + }), + ).toBe(true); + }); + + it("shows a sole remote environment so the user knows where the project runs", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: { isPrimary: false }, + canPickEnvironment: false, + }), + ).toBe(true); + }); + + it("hides a sole primary (this-device) environment", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: { isPrimary: true }, + canPickEnvironment: false, + }), + ).toBe(false); + }); + + it("hides the indicator when the active environment is unknown", () => { + expect( + shouldShowEnvironmentIndicator({ + activeEnvironment: null, + canPickEnvironment: false, + }), + ).toBe(false); + }); +}); + describe("resolveEffectiveEnvMode", () => { it("treats draft threads already attached to a worktree as current-checkout mode", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 65388962c08..b16e1f590a9 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -42,6 +42,17 @@ export function resolveEnvironmentOptionLabel(input: { return runtimeLabel ?? savedLabel ?? input.environmentId; } +// A remote (non-primary) environment is always surfaced, even when it is the +// only environment available: with a single connected machine there is nothing +// to pick, but the user still needs to see where the project runs. +export function shouldShowEnvironmentIndicator(input: { + activeEnvironment: Pick | null; + canPickEnvironment: boolean; +}): boolean { + if (input.canPickEnvironment) return true; + return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary; +} + export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 958cb3743c7..0354c2e0cd7 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -20,6 +20,7 @@ import { resolveEnvModeLabel, resolveEffectiveEnvMode, resolveLockedWorkspaceLabel, + shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; @@ -60,6 +61,7 @@ interface MobileRunContextSelectorProps { environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[] | undefined; showEnvironmentPicker: boolean; + showEnvironmentIndicator: boolean; onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; @@ -72,6 +74,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ environmentId, availableEnvironments, showEnvironmentPicker, + showEnvironmentIndicator, onEnvironmentChange, effectiveEnvMode, activeWorktreePath, @@ -94,7 +97,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ : resolveCurrentWorkspaceLabel(activeWorktreePath); const isLocked = envLocked || envModeLocked; const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; - const icon = showEnvironmentPicker ? ( + const icon = showEnvironmentIndicator ? ( // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. @@ -108,7 +111,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ <> {icon} - {showEnvironmentPicker ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} + {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} ); @@ -234,6 +237,12 @@ export const BranchToolbar = memo(function BranchToolbar({ const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, ); + const activeEnvironmentOption = + availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null; + const showEnvironmentIndicator = shouldShowEnvironmentIndicator({ + activeEnvironment: activeEnvironmentOption, + canPickEnvironment: showEnvironmentPicker, + }); const isMobile = useIsMobile(); if (!hasActiveThread || !activeProject) return null; @@ -247,6 +256,7 @@ export const BranchToolbar = memo(function BranchToolbar({ environmentId={environmentId} availableEnvironments={availableEnvironments} showEnvironmentPicker={showEnvironmentPicker} + showEnvironmentIndicator={showEnvironmentIndicator} onEnvironmentChange={onEnvironmentChange} effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} @@ -254,13 +264,13 @@ export const BranchToolbar = memo(function BranchToolbar({ /> ) : (
- {showEnvironmentPicker && availableEnvironments && onEnvironmentChange && ( + {showEnvironmentIndicator && availableEnvironments && ( <> diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 35fc8b6a190..dbc742bea5a 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -17,7 +17,9 @@ interface BranchToolbarEnvironmentSelectorProps { envLocked: boolean; environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[]; - onEnvironmentChange: (environmentId: EnvironmentId) => void; + // Absent when there is only one environment to show: the indicator still + // renders (as a static label) so remote projects are always identifiable. + onEnvironmentChange?: (environmentId: EnvironmentId) => void; } export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ @@ -39,7 +41,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir [availableEnvironments], ); - if (envLocked) { + if (envLocked || onEnvironmentChange === undefined) { return ( {activeEnvironment?.isPrimary ? (