Skip to content

Commit d8180e9

Browse files
authored
🤖 Add SSH runtime badge to workspace UI (#427)
Visual indicator showing which SSH host a workspace is running on. **Badge displays:** - Server rack SVG icon + hostname - Accent blue styling with subtle border - Tooltip with full SSH host string **Appears in:** - Workspace list items (sidebar) - left of workspace name - Workspace title header (chat area) - between git status and project/branch **Implementation:** - New `RuntimeBadge` component with SVG icon - `extractSshHostname()` utility handles various SSH host formats (hostname, user@host, host:port) - Uses existing `runtimeConfig` from `WorkspaceMetadata` (no new IPC) - Badge hidden for local workspaces - Comprehensive test coverage (7 tests) _Generated with `cmux`_
1 parent 84b8df6 commit d8180e9

File tree

7 files changed

+195
-24
lines changed

7 files changed

+195
-24
lines changed

src/App.stories.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,11 @@ export const SingleProject: Story = {
166166
projectPath: "/home/user/projects/my-app",
167167
projectName: "my-app",
168168
namedWorkspacePath: "/home/user/.cmux/src/my-app/feature-auth",
169+
runtimeConfig: {
170+
type: "ssh",
171+
host: "dev-server.example.com",
172+
srcBaseDir: "/home/user/.cmux/src",
173+
},
169174
},
170175
{
171176
id: "my-app-bugfix",
@@ -249,13 +254,23 @@ export const MultipleProjects: Story = {
249254
projectPath: "/home/user/projects/backend",
250255
projectName: "backend",
251256
namedWorkspacePath: "/home/user/.cmux/src/backend/api-v2",
257+
runtimeConfig: {
258+
type: "ssh",
259+
host: "prod-server.example.com",
260+
srcBaseDir: "/home/user/.cmux/src",
261+
},
252262
},
253263
{
254264
id: "5e6f7a8b9c",
255265
name: "db-migration",
256266
projectPath: "/home/user/projects/backend",
257267
projectName: "backend",
258268
namedWorkspacePath: "/home/user/.cmux/src/backend/db-migration",
269+
runtimeConfig: {
270+
type: "ssh",
271+
host: "staging.example.com",
272+
srcBaseDir: "/home/user/.cmux/src",
273+
},
259274
},
260275
{
261276
id: "6f7a8b9c0d",

src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,9 @@ function AppInner() {
647647
selectedWorkspace.workspaceId
648648
}
649649
namedWorkspacePath={selectedWorkspace.namedWorkspacePath ?? ""}
650+
runtimeConfig={
651+
workspaceMetadata.get(selectedWorkspace.workspaceId)?.runtimeConfig
652+
}
650653
/>
651654
</ErrorBoundary>
652655
) : (

src/components/AIView.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,20 @@ import { useWorkspaceState, useWorkspaceAggregator } from "@/stores/WorkspaceSto
2424
import { StatusIndicator } from "./StatusIndicator";
2525
import { getModelName } from "@/utils/ai/models";
2626
import { GitStatusIndicator } from "./GitStatusIndicator";
27+
import { RuntimeBadge } from "./RuntimeBadge";
2728

2829
import { useGitStatus } from "@/stores/GitStatusStore";
2930
import { TooltipWrapper, Tooltip } from "./Tooltip";
3031
import type { DisplayedMessage } from "@/types/message";
32+
import type { RuntimeConfig } from "@/types/runtime";
3133
import { useAIViewKeybinds } from "@/hooks/useAIViewKeybinds";
3234

3335
interface AIViewProps {
3436
workspaceId: string;
3537
projectName: string;
3638
branch: string;
3739
namedWorkspacePath: string; // User-friendly path for display and terminal
40+
runtimeConfig?: RuntimeConfig;
3841
className?: string;
3942
}
4043

@@ -43,6 +46,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
4346
projectName,
4447
branch,
4548
namedWorkspacePath,
49+
runtimeConfig,
4650
className,
4751
}) => {
4852
const chatAreaRef = useRef<HTMLDivElement>(null);
@@ -348,6 +352,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
348352
workspaceId={workspaceId}
349353
tooltipPosition="bottom"
350354
/>
355+
<RuntimeBadge runtimeConfig={runtimeConfig} />
351356
<span className="min-w-0 truncate font-mono text-xs">
352357
{projectName} / {branch}
353358
</span>

src/components/RuntimeBadge.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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+
<svg
33+
width="12"
34+
height="12"
35+
viewBox="0 0 16 16"
36+
fill="none"
37+
stroke="currentColor"
38+
strokeWidth="1.5"
39+
strokeLinecap="round"
40+
strokeLinejoin="round"
41+
aria-label="SSH Runtime"
42+
>
43+
{/* Server rack icon */}
44+
<rect x="2" y="2" width="12" height="4" rx="1" />
45+
<rect x="2" y="10" width="12" height="4" rx="1" />
46+
<line x1="5" y1="4" x2="5" y2="4" />
47+
<line x1="5" y1="12" x2="5" y2="12" />
48+
</svg>
49+
<span className="truncate">{hostname}</span>
50+
</span>
51+
<Tooltip align="right">
52+
Running on SSH host: {runtimeConfig?.type === "ssh" ? runtimeConfig.host : hostname}
53+
</Tooltip>
54+
</TooltipWrapper>
55+
);
56+
}

src/components/WorkspaceListItem.tsx

Lines changed: 28 additions & 24 deletions
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;
@@ -181,30 +182,33 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
181182
workspaceId={workspaceId}
182183
tooltipPosition="right"
183184
/>
184-
{isEditing ? (
185-
<input
186-
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"
187-
value={editingName}
188-
onChange={(e) => setEditingName(e.target.value)}
189-
onKeyDown={handleRenameKeyDown}
190-
onBlur={() => void handleConfirmRename()}
191-
autoFocus
192-
onClick={(e) => e.stopPropagation()}
193-
aria-label={`Rename workspace ${displayName}`}
194-
data-workspace-id={workspaceId}
195-
/>
196-
) : (
197-
<span
198-
className="text-foreground min-w-0 cursor-pointer truncate rounded-sm px-1 py-0.5 text-right text-[14px] transition-colors duration-200 hover:bg-white/5"
199-
onDoubleClick={(e) => {
200-
e.stopPropagation();
201-
startRenaming();
202-
}}
203-
title="Double-click to rename"
204-
>
205-
{displayName}
206-
</span>
207-
)}
185+
<div className="flex min-w-0 items-center justify-end gap-1.5">
186+
<RuntimeBadge runtimeConfig={metadata.runtimeConfig} />
187+
{isEditing ? (
188+
<input
189+
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"
190+
value={editingName}
191+
onChange={(e) => setEditingName(e.target.value)}
192+
onKeyDown={handleRenameKeyDown}
193+
onBlur={() => void handleConfirmRename()}
194+
autoFocus
195+
onClick={(e) => e.stopPropagation()}
196+
aria-label={`Rename workspace ${displayName}`}
197+
data-workspace-id={workspaceId}
198+
/>
199+
) : (
200+
<span
201+
className="text-foreground min-w-0 cursor-pointer truncate rounded-sm px-1 py-0.5 text-right text-[14px] transition-colors duration-200 hover:bg-white/5"
202+
onDoubleClick={(e) => {
203+
e.stopPropagation();
204+
startRenaming();
205+
}}
206+
title="Double-click to rename"
207+
>
208+
{displayName}
209+
</span>
210+
)}
211+
</div>
208212
<StatusIndicator
209213
className="ml-2"
210214
streaming={isStreaming}

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)