Skip to content

Commit e585296

Browse files
committed
🤖 feat: icon-based runtime selector for new workspace page
Replace dropdown selector with three clickable icons (Local, Worktree, SSH): - Selected icon shows active styling (brighter colors) - Tooltips show runtime name and description - Clicking an icon sets it as the project default Simplify runtime persistence: - Single default runtime per project (worktree by default) - Default can only be changed via icon selector - Slash command runtime overrides are one-time, don't change default Extract shared RuntimeIcons component for reuse with RuntimeBadge.
1 parent 3b530f7 commit e585296

File tree

12 files changed

+263
-200
lines changed

12 files changed

+263
-200
lines changed

src/browser/components/ChatInput/CreationControls.tsx

Lines changed: 12 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import React from "react";
22
import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime";
3-
import { TooltipWrapper, Tooltip } from "../Tooltip";
43
import { Select } from "../Select";
4+
import { RuntimeIconSelector } from "../RuntimeIconSelector";
55

66
interface CreationControlsProps {
77
branches: string[];
88
trunkBranch: string;
99
onTrunkBranchChange: (branch: string) => void;
1010
runtimeMode: RuntimeMode;
1111
sshHost: string;
12-
onRuntimeChange: (mode: RuntimeMode, host: string) => void;
12+
/** Called when user changes runtime mode via checkbox in tooltip */
13+
onRuntimeModeChange: (mode: RuntimeMode) => void;
14+
/** Called when user changes SSH host */
15+
onSshHostChange: (host: string) => void;
1316
disabled: boolean;
1417
}
1518

@@ -25,40 +28,12 @@ export function CreationControls(props: CreationControlsProps) {
2528

2629
return (
2730
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
28-
{/* Runtime Selector - first */}
29-
<div
30-
className="flex items-center gap-1"
31-
data-component="RuntimeSelectorGroup"
32-
data-tutorial="runtime-selector"
33-
>
34-
<label className="text-muted text-xs">Runtime:</label>
35-
<Select
36-
value={props.runtimeMode}
37-
options={[
38-
{ value: RUNTIME_MODE.LOCAL, label: "Local" },
39-
{ value: RUNTIME_MODE.WORKTREE, label: "Worktree" },
40-
{ value: RUNTIME_MODE.SSH, label: "SSH" },
41-
]}
42-
onChange={(newMode) => {
43-
const mode = newMode as RuntimeMode;
44-
// Preserve SSH host across mode switches so it's remembered when returning to SSH
45-
props.onRuntimeChange(mode, props.sshHost);
46-
}}
47-
disabled={props.disabled}
48-
aria-label="Runtime mode"
49-
/>
50-
<TooltipWrapper inline>
51-
<span className="text-muted cursor-help text-xs">?</span>
52-
<Tooltip className="tooltip" align="center" width="wide">
53-
<strong>Runtime:</strong>
54-
<br />
55-
• Local: work directly in project directory (no isolation)
56-
<br />
57-
• Worktree: git worktree in ~/.mux/src (isolated)
58-
<br />• SSH: remote clone on SSH host
59-
</Tooltip>
60-
</TooltipWrapper>
61-
</div>
31+
{/* Runtime Selector - icon-based with tooltips */}
32+
<RuntimeIconSelector
33+
value={props.runtimeMode}
34+
onChange={props.onRuntimeModeChange}
35+
disabled={props.disabled}
36+
/>
6237

6338
{/* Trunk Branch Selector - hidden for Local runtime */}
6439
{showTrunkBranchSelector && (
@@ -86,7 +61,7 @@ export function CreationControls(props: CreationControlsProps) {
8661
<input
8762
type="text"
8863
value={props.sshHost}
89-
onChange={(e) => props.onRuntimeChange(RUNTIME_MODE.SSH, e.target.value)}
64+
onChange={(e) => props.onSshHostChange(e.target.value)}
9065
placeholder="user@host"
9166
disabled={props.disabled}
9267
className="bg-separator text-foreground border-border-medium focus:border-accent w-32 rounded border px-1 py-0.5 text-xs focus:outline-none disabled:opacity-50"

src/browser/components/ChatInput/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1383,7 +1383,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
13831383
onTrunkBranchChange={creationState.setTrunkBranch}
13841384
runtimeMode={creationState.runtimeMode}
13851385
sshHost={creationState.sshHost}
1386-
onRuntimeChange={creationState.setRuntimeOptions}
1386+
onRuntimeModeChange={creationState.setRuntimeMode}
1387+
onSshHostChange={creationState.setSshHost}
13871388
disabled={creationState.isSending || isSending}
13881389
/>
13891390
)}

src/browser/components/ChatInput/useCreationWorkspace.test.tsx

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -487,27 +487,32 @@ function createDraftSettingsHarness(
487487
runtimeString: string | undefined;
488488
};
489489

490-
const setRuntimeOptions = mock((mode: RuntimeMode, host: string) => {
491-
state.runtimeMode = mode;
492-
state.sshHost = host;
493-
const trimmedHost = host.trim();
494-
state.runtimeString = mode === "ssh" ? (trimmedHost ? `ssh ${trimmedHost}` : "ssh") : undefined;
495-
});
496-
497490
const setTrunkBranch = mock((branch: string) => {
498491
state.trunkBranch = branch;
499492
});
500493

501494
const getRuntimeString = mock(() => state.runtimeString);
502495

496+
const setRuntimeMode = mock((mode: RuntimeMode) => {
497+
state.runtimeMode = mode;
498+
const trimmedHost = state.sshHost.trim();
499+
state.runtimeString = mode === "ssh" ? (trimmedHost ? `ssh ${trimmedHost}` : "ssh") : undefined;
500+
});
501+
502+
const setSshHost = mock((host: string) => {
503+
state.sshHost = host;
504+
});
505+
503506
return {
504507
state,
505-
setRuntimeOptions,
508+
setRuntimeMode,
509+
setSshHost,
506510
setTrunkBranch,
507511
getRuntimeString,
508512
snapshot(): {
509513
settings: DraftWorkspaceSettings;
510-
setRuntimeOptions: typeof setRuntimeOptions;
514+
setRuntimeMode: typeof setRuntimeMode;
515+
setSshHost: typeof setSshHost;
511516
setTrunkBranch: typeof setTrunkBranch;
512517
getRuntimeString: typeof getRuntimeString;
513518
} {
@@ -521,7 +526,8 @@ function createDraftSettingsHarness(
521526
};
522527
return {
523528
settings,
524-
setRuntimeOptions,
529+
setRuntimeMode,
530+
setSshHost,
525531
setTrunkBranch,
526532
getRuntimeString,
527533
};

src/browser/components/ChatInput/useCreationWorkspace.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ interface UseCreationWorkspaceReturn {
4747
setTrunkBranch: (branch: string) => void;
4848
runtimeMode: RuntimeMode;
4949
sshHost: string;
50-
setRuntimeOptions: (mode: RuntimeMode, host: string) => void;
50+
/** Set the default runtime mode for this project (only via checkbox) */
51+
setRuntimeMode: (mode: RuntimeMode) => void;
52+
/** Set the SSH host (persisted separately from runtime mode) */
53+
setSshHost: (host: string) => void;
5154
toast: Toast | null;
5255
setToast: (toast: Toast | null) => void;
5356
isSending: boolean;
@@ -72,7 +75,7 @@ export function useCreationWorkspace({
7275
const [isSending, setIsSending] = useState(false);
7376

7477
// Centralized draft workspace settings with automatic persistence
75-
const { settings, setRuntimeOptions, setTrunkBranch, getRuntimeString } =
78+
const { settings, setRuntimeMode, setSshHost, setTrunkBranch, getRuntimeString } =
7679
useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk);
7780

7881
// Get send options from shared hook (uses project-scoped storage key)
@@ -181,7 +184,8 @@ export function useCreationWorkspace({
181184
setTrunkBranch,
182185
runtimeMode: settings.runtimeMode,
183186
sshHost: settings.sshHost,
184-
setRuntimeOptions,
187+
setRuntimeMode,
188+
setSshHost,
185189
toast,
186190
setToast,
187191
isSending,

src/browser/components/RuntimeBadge.tsx

Lines changed: 1 addition & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { RuntimeConfig } from "@/common/types/runtime";
44
import { isSSHRuntime, isWorktreeRuntime, isLocalProjectRuntime } from "@/common/types/runtime";
55
import { extractSshHostname } from "@/browser/utils/ui/runtimeBadge";
66
import { TooltipWrapper, Tooltip } from "./Tooltip";
7+
import { SSHIcon, WorktreeIcon, LocalIcon } from "./icons/RuntimeIcons";
78

89
interface RuntimeBadgeProps {
910
runtimeConfig?: RuntimeConfig;
@@ -12,72 +13,6 @@ interface RuntimeBadgeProps {
1213
isWorking?: boolean;
1314
}
1415

15-
/** Server rack icon for SSH runtime */
16-
function SSHIcon() {
17-
return (
18-
<svg
19-
width="10"
20-
height="10"
21-
viewBox="0 0 16 16"
22-
fill="none"
23-
stroke="currentColor"
24-
strokeWidth="1.5"
25-
strokeLinecap="round"
26-
strokeLinejoin="round"
27-
aria-label="SSH Runtime"
28-
>
29-
<rect x="2" y="2" width="12" height="5" rx="1" />
30-
<rect x="2" y="9" width="12" height="5" rx="1" />
31-
<circle cx="5" cy="4.5" r="0.5" fill="currentColor" />
32-
<circle cx="5" cy="11.5" r="0.5" fill="currentColor" />
33-
</svg>
34-
);
35-
}
36-
37-
/** Git branch icon for worktree runtime */
38-
function WorktreeIcon() {
39-
return (
40-
<svg
41-
width="10"
42-
height="10"
43-
viewBox="0 0 16 16"
44-
fill="none"
45-
stroke="currentColor"
46-
strokeWidth="1.5"
47-
strokeLinecap="round"
48-
strokeLinejoin="round"
49-
aria-label="Worktree Runtime"
50-
>
51-
{/* Simplified git branch: vertical line with branch off */}
52-
<circle cx="8" cy="3" r="2" />
53-
<circle cx="8" cy="13" r="2" />
54-
<line x1="8" y1="5" x2="8" y2="11" />
55-
<circle cx="12" cy="7" r="2" />
56-
<path d="M10 7 L8 9" />
57-
</svg>
58-
);
59-
}
60-
61-
/** Folder icon for local project-dir runtime */
62-
function LocalIcon() {
63-
return (
64-
<svg
65-
width="10"
66-
height="10"
67-
viewBox="0 0 16 16"
68-
fill="none"
69-
stroke="currentColor"
70-
strokeWidth="1.5"
71-
strokeLinecap="round"
72-
strokeLinejoin="round"
73-
aria-label="Local Runtime"
74-
>
75-
{/* Folder icon */}
76-
<path d="M2 4 L2 13 L14 13 L14 5 L8 5 L7 3 L2 3 L2 4" />
77-
</svg>
78-
);
79-
}
80-
8116
// Runtime-specific color schemes - each type has consistent colors in idle/working states
8217
// Idle: subtle with visible colored border for discrimination
8318
// Working: brighter colors with pulse animation
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import React from "react";
2+
import { cn } from "@/common/lib/utils";
3+
import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime";
4+
import { SSHIcon, WorktreeIcon, LocalIcon } from "./icons/RuntimeIcons";
5+
import { TooltipWrapper, Tooltip } from "./Tooltip";
6+
7+
interface RuntimeIconSelectorProps {
8+
value: RuntimeMode;
9+
onChange: (mode: RuntimeMode) => void;
10+
disabled?: boolean;
11+
className?: string;
12+
}
13+
14+
// Runtime-specific color schemes matching RuntimeBadge
15+
// Selected (active) uses the "working" styling, unselected uses "idle"
16+
const RUNTIME_STYLES = {
17+
ssh: {
18+
idle: "bg-transparent text-muted border-blue-500/30 hover:border-blue-500/50",
19+
active: "bg-blue-500/20 text-blue-400 border-blue-500/60",
20+
},
21+
worktree: {
22+
idle: "bg-transparent text-muted border-purple-500/30 hover:border-purple-500/50",
23+
active: "bg-purple-500/20 text-purple-400 border-purple-500/60",
24+
},
25+
local: {
26+
idle: "bg-transparent text-muted border-muted/30 hover:border-muted/50",
27+
active: "bg-muted/30 text-foreground border-muted/60",
28+
},
29+
} as const;
30+
31+
const RUNTIME_INFO: Record<RuntimeMode, { label: string; description: string }> = {
32+
local: {
33+
label: "Local",
34+
description: "Work directly in project directory (no isolation)",
35+
},
36+
worktree: {
37+
label: "Worktree",
38+
description: "Git worktree in ~/.mux/src (isolated)",
39+
},
40+
ssh: {
41+
label: "SSH",
42+
description: "Remote clone on SSH host",
43+
},
44+
};
45+
46+
interface RuntimeIconButtonProps {
47+
mode: RuntimeMode;
48+
isSelected: boolean;
49+
onClick: () => void;
50+
disabled?: boolean;
51+
}
52+
53+
function RuntimeIconButton(props: RuntimeIconButtonProps) {
54+
const info = RUNTIME_INFO[props.mode];
55+
const styles = RUNTIME_STYLES[props.mode];
56+
const stateStyle = props.isSelected ? styles.active : styles.idle;
57+
58+
const Icon =
59+
props.mode === RUNTIME_MODE.SSH
60+
? SSHIcon
61+
: props.mode === RUNTIME_MODE.WORKTREE
62+
? WorktreeIcon
63+
: LocalIcon;
64+
65+
return (
66+
<TooltipWrapper inline>
67+
<button
68+
type="button"
69+
onClick={props.onClick}
70+
disabled={props.disabled}
71+
className={cn(
72+
"inline-flex items-center justify-center rounded border p-1.5 transition-colors",
73+
"focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-500",
74+
stateStyle,
75+
props.disabled && "cursor-not-allowed opacity-50"
76+
)}
77+
aria-label={`${info.label} runtime`}
78+
aria-pressed={props.isSelected}
79+
>
80+
<Icon size={14} />
81+
</button>
82+
<Tooltip align="center" width="wide" position="bottom">
83+
<strong>{info.label}</strong>
84+
<p className="text-muted mt-0.5">{info.description}</p>
85+
</Tooltip>
86+
</TooltipWrapper>
87+
);
88+
}
89+
90+
/**
91+
* Runtime selector using icons with tooltips.
92+
* Shows Local, Worktree, and SSH options as clickable icons.
93+
* Selected runtime uses "active" styling (brighter colors).
94+
* Clicking an icon sets it as the project default.
95+
*/
96+
export function RuntimeIconSelector(props: RuntimeIconSelectorProps) {
97+
const modes: RuntimeMode[] = [RUNTIME_MODE.LOCAL, RUNTIME_MODE.WORKTREE, RUNTIME_MODE.SSH];
98+
99+
return (
100+
<div
101+
className={cn("inline-flex items-center gap-1", props.className)}
102+
data-component="RuntimeIconSelector"
103+
data-tutorial="runtime-selector"
104+
>
105+
{modes.map((mode) => (
106+
<RuntimeIconButton
107+
key={mode}
108+
mode={mode}
109+
isSelected={props.value === mode}
110+
onClick={() => props.onChange(mode)}
111+
disabled={props.disabled}
112+
/>
113+
))}
114+
</div>
115+
);
116+
}

0 commit comments

Comments
 (0)