diff --git a/src/App.stories.tsx b/src/App.stories.tsx index c68c21e53f..fe34d49e4f 100644 --- a/src/App.stories.tsx +++ b/src/App.stories.tsx @@ -166,6 +166,11 @@ export const SingleProject: Story = { projectPath: "/home/user/projects/my-app", projectName: "my-app", namedWorkspacePath: "/home/user/.cmux/src/my-app/feature-auth", + runtimeConfig: { + type: "ssh", + host: "dev-server.example.com", + srcBaseDir: "/home/user/.cmux/src", + }, }, { id: "my-app-bugfix", @@ -249,6 +254,11 @@ export const MultipleProjects: Story = { projectPath: "/home/user/projects/backend", projectName: "backend", namedWorkspacePath: "/home/user/.cmux/src/backend/api-v2", + runtimeConfig: { + type: "ssh", + host: "prod-server.example.com", + srcBaseDir: "/home/user/.cmux/src", + }, }, { id: "5e6f7a8b9c", @@ -256,6 +266,11 @@ export const MultipleProjects: Story = { projectPath: "/home/user/projects/backend", projectName: "backend", namedWorkspacePath: "/home/user/.cmux/src/backend/db-migration", + runtimeConfig: { + type: "ssh", + host: "staging.example.com", + srcBaseDir: "/home/user/.cmux/src", + }, }, { id: "6f7a8b9c0d", diff --git a/src/App.tsx b/src/App.tsx index 7092fa9fc7..f27a3a3254 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -641,6 +641,9 @@ function AppInner() { selectedWorkspace.workspaceId } namedWorkspacePath={selectedWorkspace.namedWorkspacePath ?? ""} + runtimeConfig={ + workspaceMetadata.get(selectedWorkspace.workspaceId)?.runtimeConfig + } /> ) : ( diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index a2a63e019e..b1d635a7c1 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -24,10 +24,12 @@ import { useWorkspaceState, useWorkspaceAggregator } from "@/stores/WorkspaceSto import { StatusIndicator } from "./StatusIndicator"; import { getModelName } from "@/utils/ai/models"; import { GitStatusIndicator } from "./GitStatusIndicator"; +import { RuntimeBadge } from "./RuntimeBadge"; import { useGitStatus } from "@/stores/GitStatusStore"; import { TooltipWrapper, Tooltip } from "./Tooltip"; import type { DisplayedMessage } from "@/types/message"; +import type { RuntimeConfig } from "@/types/runtime"; import { useAIViewKeybinds } from "@/hooks/useAIViewKeybinds"; interface AIViewProps { @@ -35,6 +37,7 @@ interface AIViewProps { projectName: string; branch: string; namedWorkspacePath: string; // User-friendly path for display and terminal + runtimeConfig?: RuntimeConfig; className?: string; } @@ -43,6 +46,7 @@ const AIViewInner: React.FC = ({ projectName, branch, namedWorkspacePath, + runtimeConfig, className, }) => { const chatAreaRef = useRef(null); @@ -348,6 +352,7 @@ const AIViewInner: React.FC = ({ workspaceId={workspaceId} tooltipPosition="bottom" /> + {projectName} / {branch} diff --git a/src/components/RuntimeBadge.tsx b/src/components/RuntimeBadge.tsx new file mode 100644 index 0000000000..886cc776f1 --- /dev/null +++ b/src/components/RuntimeBadge.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { cn } from "@/lib/utils"; +import type { RuntimeConfig } from "@/types/runtime"; +import { extractSshHostname } from "@/utils/ui/runtimeBadge"; +import { TooltipWrapper, Tooltip } from "./Tooltip"; + +interface RuntimeBadgeProps { + runtimeConfig?: RuntimeConfig; + className?: string; +} + +/** + * Badge to display SSH runtime information. + * Shows compute icon + hostname for SSH runtimes, nothing for local. + */ +export function RuntimeBadge({ runtimeConfig, className }: RuntimeBadgeProps) { + const hostname = extractSshHostname(runtimeConfig); + + if (!hostname) { + return null; + } + + return ( + + + + {/* Server rack icon */} + + + + + + {hostname} + + + Running on SSH host: {runtimeConfig?.type === "ssh" ? runtimeConfig.host : hostname} + + + ); +} diff --git a/src/components/WorkspaceListItem.tsx b/src/components/WorkspaceListItem.tsx index b017538d07..4ba1755c45 100644 --- a/src/components/WorkspaceListItem.tsx +++ b/src/components/WorkspaceListItem.tsx @@ -9,6 +9,7 @@ import { ModelDisplay } from "./Messages/ModelDisplay"; import { StatusIndicator } from "./StatusIndicator"; import { useRename } from "@/contexts/WorkspaceRenameContext"; import { cn } from "@/lib/utils"; +import { RuntimeBadge } from "./RuntimeBadge"; export interface WorkspaceSelection { projectPath: string; @@ -181,30 +182,33 @@ const WorkspaceListItemInner: React.FC = ({ workspaceId={workspaceId} tooltipPosition="right" /> - {isEditing ? ( - setEditingName(e.target.value)} - onKeyDown={handleRenameKeyDown} - onBlur={() => void handleConfirmRename()} - autoFocus - onClick={(e) => e.stopPropagation()} - aria-label={`Rename workspace ${displayName}`} - data-workspace-id={workspaceId} - /> - ) : ( - { - e.stopPropagation(); - startRenaming(); - }} - title="Double-click to rename" - > - {displayName} - - )} +
+ + {isEditing ? ( + setEditingName(e.target.value)} + onKeyDown={handleRenameKeyDown} + onBlur={() => void handleConfirmRename()} + autoFocus + onClick={(e) => e.stopPropagation()} + aria-label={`Rename workspace ${displayName}`} + data-workspace-id={workspaceId} + /> + ) : ( + { + e.stopPropagation(); + startRenaming(); + }} + title="Double-click to rename" + > + {displayName} + + )} +
{ + it("should return null for undefined runtime config", () => { + expect(extractSshHostname(undefined)).toBeNull(); + }); + + it("should return null for local runtime", () => { + const config: RuntimeConfig = { + type: "local", + srcBaseDir: "/home/user/.cmux/src", + }; + expect(extractSshHostname(config)).toBeNull(); + }); + + it("should extract hostname from simple host", () => { + const config: RuntimeConfig = { + type: "ssh", + host: "myserver", + srcBaseDir: "/home/user/.cmux/src", + }; + expect(extractSshHostname(config)).toBe("myserver"); + }); + + it("should extract hostname from user@host format", () => { + const config: RuntimeConfig = { + type: "ssh", + host: "user@myserver.example.com", + srcBaseDir: "/home/user/.cmux/src", + }; + expect(extractSshHostname(config)).toBe("myserver.example.com"); + }); + + it("should handle hostname with port in host string", () => { + const config: RuntimeConfig = { + type: "ssh", + host: "myserver:2222", + srcBaseDir: "/home/user/.cmux/src", + }; + expect(extractSshHostname(config)).toBe("myserver"); + }); + + it("should handle user@host:port format", () => { + const config: RuntimeConfig = { + type: "ssh", + host: "user@myserver.example.com:2222", + srcBaseDir: "/home/user/.cmux/src", + }; + expect(extractSshHostname(config)).toBe("myserver.example.com"); + }); + + it("should handle SSH config alias", () => { + const config: RuntimeConfig = { + type: "ssh", + host: "my-server-alias", + srcBaseDir: "/home/user/.cmux/src", + }; + expect(extractSshHostname(config)).toBe("my-server-alias"); + }); +}); diff --git a/src/utils/ui/runtimeBadge.ts b/src/utils/ui/runtimeBadge.ts new file mode 100644 index 0000000000..95d5e67d2f --- /dev/null +++ b/src/utils/ui/runtimeBadge.ts @@ -0,0 +1,26 @@ +import type { RuntimeConfig } from "@/types/runtime"; + +/** + * Extract hostname from SSH runtime config. + * Returns null if runtime is local or not configured. + * + * Examples: + * - "hostname" -> "hostname" + * - "user@hostname" -> "hostname" + * - "user@hostname:port" -> "hostname" + */ +export function extractSshHostname(runtimeConfig?: RuntimeConfig): string | null { + if (!runtimeConfig?.type || runtimeConfig.type !== "ssh") { + return null; + } + + const { host } = runtimeConfig; + + // Remove user@ prefix if present + const withoutUser = host.includes("@") ? host.split("@")[1] : host; + + // Remove :port suffix if present (though port is usually in separate field) + const hostname = withoutUser.split(":")[0]; + + return hostname || null; +}