From a1271c3c606a431d8dfa8649e502076e63fb8914 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 26 Oct 2025 12:51:09 -0500 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=A4=96=20Add=20SSH=20runtime=20badge?= =?UTF-8?q?=20to=20workspace=20sidebar=20and=20title=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` --- src/components/RuntimeBadge.tsx | 42 +++++++++++++++++++ src/components/TitleBar.tsx | 9 ++++ src/components/WorkspaceListItem.tsx | 4 +- src/utils/ui/runtimeBadge.test.ts | 62 ++++++++++++++++++++++++++++ src/utils/ui/runtimeBadge.ts | 26 ++++++++++++ 5 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 src/components/RuntimeBadge.tsx create mode 100644 src/utils/ui/runtimeBadge.test.ts create mode 100644 src/utils/ui/runtimeBadge.ts diff --git a/src/components/RuntimeBadge.tsx b/src/components/RuntimeBadge.tsx new file mode 100644 index 0000000000..19c0a89111 --- /dev/null +++ b/src/components/RuntimeBadge.tsx @@ -0,0 +1,42 @@ +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 ( + + + + 🖥️ + + {hostname} + + + Running on SSH host: {runtimeConfig?.type === "ssh" ? runtimeConfig.host : hostname} + + + ); +} diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 80313ee963..95e6a4abfc 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -4,6 +4,8 @@ import { VERSION } from "@/version"; import { TooltipWrapper, Tooltip } from "./Tooltip"; import type { UpdateStatus } from "@/types/ipc"; import { isTelemetryEnabled } from "@/telemetry"; +import { useApp } from "@/contexts/AppContext"; +import { RuntimeBadge } from "./RuntimeBadge"; // Update check intervals const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours @@ -78,6 +80,12 @@ export function TitleBar() { const lastHoverCheckTime = useRef(0); const telemetryEnabled = isTelemetryEnabled(); + // Get selected workspace runtime config + const { selectedWorkspace, workspaceMetadata } = useApp(); + const runtimeConfig = selectedWorkspace?.workspaceId + ? workspaceMetadata.get(selectedWorkspace.workspaceId)?.runtimeConfig + : undefined; + useEffect(() => { // Skip update checks if telemetry is disabled if (!telemetryEnabled) { @@ -242,6 +250,7 @@ export function TitleBar() { )} +
cmux {gitDescribe ?? "(dev)"}
diff --git a/src/components/WorkspaceListItem.tsx b/src/components/WorkspaceListItem.tsx index b017538d07..927ee2a63c 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; @@ -132,7 +133,7 @@ const WorkspaceListItemInner: React.FC = ({
@@ -181,6 +182,7 @@ const WorkspaceListItemInner: React.FC = ({ workspaceId={workspaceId} tooltipPosition="right" /> + {isEditing ? ( { + 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; +} From cf4a4d69accc3468d8c88c0c5973ae2248ea60be Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 26 Oct 2025 12:52:53 -0500 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=A4=96=20Fix=20grid=20layout:=20group?= =?UTF-8?q?=20runtime=20badge=20with=20workspace=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move RuntimeBadge into same grid cell as workspace name to avoid breaking the existing layout. Badge and name now share a flex container. Generated with `cmux` --- src/components/WorkspaceListItem.tsx | 54 ++++++++++++++-------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/src/components/WorkspaceListItem.tsx b/src/components/WorkspaceListItem.tsx index 927ee2a63c..310428bbd9 100644 --- a/src/components/WorkspaceListItem.tsx +++ b/src/components/WorkspaceListItem.tsx @@ -133,7 +133,7 @@ const WorkspaceListItemInner: React.FC = ({
@@ -182,31 +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} + + )} +
Date: Sun, 26 Oct 2025 12:54:08 -0500 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=A4=96=20Fix=20alignment:=20right-ali?= =?UTF-8?q?gn=20badge=20and=20workspace=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add justify-end to flex container to restore right-alignment of workspace name like it was before the badge was added. Generated with `cmux` --- src/components/WorkspaceListItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/WorkspaceListItem.tsx b/src/components/WorkspaceListItem.tsx index 310428bbd9..4ba1755c45 100644 --- a/src/components/WorkspaceListItem.tsx +++ b/src/components/WorkspaceListItem.tsx @@ -182,7 +182,7 @@ const WorkspaceListItemInner: React.FC = ({ workspaceId={workspaceId} tooltipPosition="right" /> -
+
{isEditing ? ( Date: Sun, 26 Oct 2025 12:55:27 -0500 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=A4=96=20Replace=20emoji=20with=20SVG?= =?UTF-8?q?=20icon=20for=20runtime=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 🖥️ emoji with a clean SVG server rack icon that matches the app's visual style. Icon uses currentColor to inherit the accent color from the badge. Generated with `cmux` --- src/components/RuntimeBadge.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/components/RuntimeBadge.tsx b/src/components/RuntimeBadge.tsx index 19c0a89111..886cc776f1 100644 --- a/src/components/RuntimeBadge.tsx +++ b/src/components/RuntimeBadge.tsx @@ -29,9 +29,23 @@ export function RuntimeBadge({ runtimeConfig, className }: RuntimeBadgeProps) { className )} > - - 🖥️ - + + {/* Server rack icon */} + + + + + {hostname} From 441070362aa3bef384c70a882fb7efb4adce9923 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 26 Oct 2025 13:01:46 -0500 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=A4=96=20Add=20runtime=20badge=20to?= =?UTF-8?q?=20workspace=20title=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display SSH runtime badge in the AIView workspace header alongside the status indicator and git status. Badge appears between git status and the project/branch name. Changes: - Add runtimeConfig prop to AIViewProps - Pass runtimeConfig from App.tsx using workspaceMetadata - Render RuntimeBadge in workspace title header Generated with `cmux` --- src/App.tsx | 3 +++ src/components/AIView.tsx | 5 +++++ 2 files changed, 8 insertions(+) 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} From 28ee689fc62bb325949ed232ef87e150b73fbb01 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 26 Oct 2025 13:04:27 -0500 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=A4=96=20Remove=20runtime=20badge=20f?= =?UTF-8?q?rom=20left=20sidebar=20title=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove RuntimeBadge from TitleBar component. Badge should only appear in: 1. Workspace list items (sidebar) 2. Workspace title header (main chat area) The left sidebar title bar should remain minimal with just version info. Generated with `cmux` --- src/components/TitleBar.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 95e6a4abfc..80313ee963 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -4,8 +4,6 @@ import { VERSION } from "@/version"; import { TooltipWrapper, Tooltip } from "./Tooltip"; import type { UpdateStatus } from "@/types/ipc"; import { isTelemetryEnabled } from "@/telemetry"; -import { useApp } from "@/contexts/AppContext"; -import { RuntimeBadge } from "./RuntimeBadge"; // Update check intervals const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours @@ -80,12 +78,6 @@ export function TitleBar() { const lastHoverCheckTime = useRef(0); const telemetryEnabled = isTelemetryEnabled(); - // Get selected workspace runtime config - const { selectedWorkspace, workspaceMetadata } = useApp(); - const runtimeConfig = selectedWorkspace?.workspaceId - ? workspaceMetadata.get(selectedWorkspace.workspaceId)?.runtimeConfig - : undefined; - useEffect(() => { // Skip update checks if telemetry is disabled if (!telemetryEnabled) { @@ -250,7 +242,6 @@ export function TitleBar() { )} -
cmux {gitDescribe ?? "(dev)"}
From 4eeb49aace10b19aea6f4a99777b7a21b706ee6a Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 26 Oct 2025 13:16:58 -0500 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=A4=96=20Add=20SSH=20runtime=20worksp?= =?UTF-8?q?aces=20to=20Storybook=20stories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add workspaces with SSH runtime config to App stories so the runtime badge is visible in Storybook: - SingleProject: feature/auth workspace on dev-server.example.com - MultipleProjects: api-v2 on prod-server.example.com, db-migration on staging.example.com This allows visual testing and demonstration of the runtime badge UI. Generated with `cmux` --- src/App.stories.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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",