Skip to content

Commit a1271c3

Browse files
committed
🤖 Add SSH runtime badge to workspace sidebar and title bar
Add a visual indicator showing which SSH host a workspace is running on: - Badge displays 🖥️ icon + hostname for SSH workspaces - Appears in workspace sidebar (left of workspace name) and title bar - Tooltip shows full SSH host string on hover - No badge shown for local workspaces Implementation: - New RuntimeBadge component with accent-colored styling - extractSshHostname() utility handles various SSH host formats - Uses existing runtimeConfig from WorkspaceMetadata (no new IPC) - Comprehensive test coverage for hostname extraction Generated with `cmux`
1 parent 5f200a6 commit a1271c3

File tree

5 files changed

+142
-1
lines changed

5 files changed

+142
-1
lines changed

src/components/RuntimeBadge.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import React from "react";
2+
import { cn } from "@/lib/utils";
3+
import type { RuntimeConfig } from "@/types/runtime";
4+
import { extractSshHostname } from "@/utils/ui/runtimeBadge";
5+
import { TooltipWrapper, Tooltip } from "./Tooltip";
6+
7+
interface RuntimeBadgeProps {
8+
runtimeConfig?: RuntimeConfig;
9+
className?: string;
10+
}
11+
12+
/**
13+
* Badge to display SSH runtime information.
14+
* Shows compute icon + hostname for SSH runtimes, nothing for local.
15+
*/
16+
export function RuntimeBadge({ runtimeConfig, className }: RuntimeBadgeProps) {
17+
const hostname = extractSshHostname(runtimeConfig);
18+
19+
if (!hostname) {
20+
return null;
21+
}
22+
23+
return (
24+
<TooltipWrapper inline>
25+
<span
26+
className={cn(
27+
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium",
28+
"bg-accent/10 text-accent border border-accent/30",
29+
className
30+
)}
31+
>
32+
<span className="text-[10px]" aria-label="SSH Runtime">
33+
🖥️
34+
</span>
35+
<span className="truncate">{hostname}</span>
36+
</span>
37+
<Tooltip align="right">
38+
Running on SSH host: {runtimeConfig?.type === "ssh" ? runtimeConfig.host : hostname}
39+
</Tooltip>
40+
</TooltipWrapper>
41+
);
42+
}

src/components/TitleBar.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { VERSION } from "@/version";
44
import { TooltipWrapper, Tooltip } from "./Tooltip";
55
import type { UpdateStatus } from "@/types/ipc";
66
import { isTelemetryEnabled } from "@/telemetry";
7+
import { useApp } from "@/contexts/AppContext";
8+
import { RuntimeBadge } from "./RuntimeBadge";
79

810
// Update check intervals
911
const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours
@@ -78,6 +80,12 @@ export function TitleBar() {
7880
const lastHoverCheckTime = useRef<number>(0);
7981
const telemetryEnabled = isTelemetryEnabled();
8082

83+
// Get selected workspace runtime config
84+
const { selectedWorkspace, workspaceMetadata } = useApp();
85+
const runtimeConfig = selectedWorkspace?.workspaceId
86+
? workspaceMetadata.get(selectedWorkspace.workspaceId)?.runtimeConfig
87+
: undefined;
88+
8189
useEffect(() => {
8290
// Skip update checks if telemetry is disabled
8391
if (!telemetryEnabled) {
@@ -242,6 +250,7 @@ export function TitleBar() {
242250
</Tooltip>
243251
</TooltipWrapper>
244252
)}
253+
<RuntimeBadge runtimeConfig={runtimeConfig} />
245254
<div className="min-w-0 cursor-text truncate text-xs font-normal tracking-wider select-text">
246255
cmux {gitDescribe ?? "(dev)"}
247256
</div>

src/components/WorkspaceListItem.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ModelDisplay } from "./Messages/ModelDisplay";
99
import { StatusIndicator } from "./StatusIndicator";
1010
import { useRename } from "@/contexts/WorkspaceRenameContext";
1111
import { cn } from "@/lib/utils";
12+
import { RuntimeBadge } from "./RuntimeBadge";
1213

1314
export interface WorkspaceSelection {
1415
projectPath: string;
@@ -132,7 +133,7 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
132133
<React.Fragment>
133134
<div
134135
className={cn(
135-
"py-1.5 px-3 pl-7 cursor-pointer grid grid-cols-[auto_auto_1fr_auto] gap-2 items-center border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative hover:bg-hover [&:hover_button]:opacity-100",
136+
"py-1.5 px-3 pl-7 cursor-pointer grid grid-cols-[auto_auto_auto_1fr_auto] gap-2 items-center border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative hover:bg-hover [&:hover_button]:opacity-100",
136137
isSelected && "bg-hover border-l-[#569cd6]"
137138
)}
138139
onClick={() =>
@@ -181,6 +182,7 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
181182
workspaceId={workspaceId}
182183
tooltipPosition="right"
183184
/>
185+
<RuntimeBadge runtimeConfig={metadata.runtimeConfig} />
184186
{isEditing ? (
185187
<input
186188
className="bg-input-bg text-input-text border-input-border font-inherit focus:border-input-border-focus min-w-0 rounded-sm border px-1 py-0.5 text-right text-[13px] outline-none"

src/utils/ui/runtimeBadge.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, it, expect } from "@jest/globals";
2+
import { extractSshHostname } from "./runtimeBadge";
3+
import type { RuntimeConfig } from "@/types/runtime";
4+
5+
describe("extractSshHostname", () => {
6+
it("should return null for undefined runtime config", () => {
7+
expect(extractSshHostname(undefined)).toBeNull();
8+
});
9+
10+
it("should return null for local runtime", () => {
11+
const config: RuntimeConfig = {
12+
type: "local",
13+
srcBaseDir: "/home/user/.cmux/src",
14+
};
15+
expect(extractSshHostname(config)).toBeNull();
16+
});
17+
18+
it("should extract hostname from simple host", () => {
19+
const config: RuntimeConfig = {
20+
type: "ssh",
21+
host: "myserver",
22+
srcBaseDir: "/home/user/.cmux/src",
23+
};
24+
expect(extractSshHostname(config)).toBe("myserver");
25+
});
26+
27+
it("should extract hostname from user@host format", () => {
28+
const config: RuntimeConfig = {
29+
type: "ssh",
30+
host: "user@myserver.example.com",
31+
srcBaseDir: "/home/user/.cmux/src",
32+
};
33+
expect(extractSshHostname(config)).toBe("myserver.example.com");
34+
});
35+
36+
it("should handle hostname with port in host string", () => {
37+
const config: RuntimeConfig = {
38+
type: "ssh",
39+
host: "myserver:2222",
40+
srcBaseDir: "/home/user/.cmux/src",
41+
};
42+
expect(extractSshHostname(config)).toBe("myserver");
43+
});
44+
45+
it("should handle user@host:port format", () => {
46+
const config: RuntimeConfig = {
47+
type: "ssh",
48+
host: "user@myserver.example.com:2222",
49+
srcBaseDir: "/home/user/.cmux/src",
50+
};
51+
expect(extractSshHostname(config)).toBe("myserver.example.com");
52+
});
53+
54+
it("should handle SSH config alias", () => {
55+
const config: RuntimeConfig = {
56+
type: "ssh",
57+
host: "my-server-alias",
58+
srcBaseDir: "/home/user/.cmux/src",
59+
};
60+
expect(extractSshHostname(config)).toBe("my-server-alias");
61+
});
62+
});

src/utils/ui/runtimeBadge.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { RuntimeConfig } from "@/types/runtime";
2+
3+
/**
4+
* Extract hostname from SSH runtime config.
5+
* Returns null if runtime is local or not configured.
6+
*
7+
* Examples:
8+
* - "hostname" -> "hostname"
9+
* - "user@hostname" -> "hostname"
10+
* - "user@hostname:port" -> "hostname"
11+
*/
12+
export function extractSshHostname(runtimeConfig?: RuntimeConfig): string | null {
13+
if (!runtimeConfig?.type || runtimeConfig.type !== "ssh") {
14+
return null;
15+
}
16+
17+
const { host } = runtimeConfig;
18+
19+
// Remove user@ prefix if present
20+
const withoutUser = host.includes("@") ? host.split("@")[1] : host;
21+
22+
// Remove :port suffix if present (though port is usually in separate field)
23+
const hostname = withoutUser.split(":")[0];
24+
25+
return hostname || null;
26+
}

0 commit comments

Comments
 (0)