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