Skip to content

Commit b709eb3

Browse files
authored
🤖 feat: icon-based runtime selector for new workspace page (#885)
Replace the dropdown runtime selector with three clickable icons: - **Local** (folder icon) - Work directly in project directory - **Worktree** (git branch icon) - Isolated git worktree in ~/.mux/src - **SSH** (server icon) - Remote clone on SSH host ### Behavior - Selected icon shows active styling (brighter colors matching RuntimeBadge) - Hover tooltips show runtime name and description - Clicking an icon sets it as the project default (persisted) - Worktree is the default if no selection has been made ### Implementation - Extract shared `RuntimeIcons` component (used by both RuntimeIconSelector and RuntimeBadge) - Simplify persistence: single default runtime per project (no more 'last used' vs 'explicit default') - Slash command runtime overrides are one-time and don't change the default --- _Generated with `mux`_
1 parent ac30c74 commit b709eb3

File tree

12 files changed

+332
-197
lines changed

12 files changed

+332
-197
lines changed

src/browser/components/ChatInput/CreationControls.tsx

Lines changed: 17 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
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;
11+
defaultRuntimeMode: RuntimeMode;
1112
sshHost: string;
12-
onRuntimeChange: (mode: RuntimeMode, host: string) => void;
13+
/** Called when user clicks a runtime icon to select it (does not persist) */
14+
onRuntimeModeChange: (mode: RuntimeMode) => void;
15+
/** Called when user checks "Default for project" checkbox (persists) */
16+
onSetDefaultRuntime: (mode: RuntimeMode) => void;
17+
/** Called when user changes SSH host */
18+
onSshHostChange: (host: string) => void;
1319
disabled: boolean;
1420
}
1521

@@ -25,40 +31,14 @@ export function CreationControls(props: CreationControlsProps) {
2531

2632
return (
2733
<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>
34+
{/* Runtime Selector - icon-based with tooltips */}
35+
<RuntimeIconSelector
36+
value={props.runtimeMode}
37+
onChange={props.onRuntimeModeChange}
38+
defaultMode={props.defaultRuntimeMode}
39+
onSetDefault={props.onSetDefaultRuntime}
40+
disabled={props.disabled}
41+
/>
6242

6343
{/* Trunk Branch Selector - hidden for Local runtime */}
6444
{showTrunkBranchSelector && (
@@ -86,7 +66,7 @@ export function CreationControls(props: CreationControlsProps) {
8666
<input
8767
type="text"
8868
value={props.sshHost}
89-
onChange={(e) => props.onRuntimeChange(RUNTIME_MODE.SSH, e.target.value)}
69+
onChange={(e) => props.onSshHostChange(e.target.value)}
9070
placeholder="user@host"
9171
disabled={props.disabled}
9272
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: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1397,8 +1397,11 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
13971397
trunkBranch={creationState.trunkBranch}
13981398
onTrunkBranchChange={creationState.setTrunkBranch}
13991399
runtimeMode={creationState.runtimeMode}
1400+
defaultRuntimeMode={creationState.defaultRuntimeMode}
14001401
sshHost={creationState.sshHost}
1401-
onRuntimeChange={creationState.setRuntimeOptions}
1402+
onRuntimeModeChange={creationState.setRuntimeMode}
1403+
onSetDefaultRuntime={creationState.setDefaultRuntimeMode}
1404+
onSshHostChange={creationState.setSshHost}
14021405
disabled={creationState.isSending || isSending}
14031406
/>
14041407
)}

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

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -473,41 +473,58 @@ function createDraftSettingsHarness(
473473
sshHost: string;
474474
trunkBranch: string;
475475
runtimeString?: string | undefined;
476+
defaultRuntimeMode?: RuntimeMode;
476477
}>
477478
) {
478479
const state = {
479480
runtimeMode: initial?.runtimeMode ?? ("local" as RuntimeMode),
481+
defaultRuntimeMode: initial?.defaultRuntimeMode ?? ("worktree" as RuntimeMode),
480482
sshHost: initial?.sshHost ?? "",
481483
trunkBranch: initial?.trunkBranch ?? "main",
482484
runtimeString: initial?.runtimeString,
483485
} satisfies {
484486
runtimeMode: RuntimeMode;
487+
defaultRuntimeMode: RuntimeMode;
485488
sshHost: string;
486489
trunkBranch: string;
487490
runtimeString: string | undefined;
488491
};
489492

490-
const setRuntimeOptions = mock((mode: RuntimeMode, host: string) => {
493+
const setTrunkBranch = mock((branch: string) => {
494+
state.trunkBranch = branch;
495+
});
496+
497+
const getRuntimeString = mock(() => state.runtimeString);
498+
499+
const setRuntimeMode = mock((mode: RuntimeMode) => {
491500
state.runtimeMode = mode;
492-
state.sshHost = host;
493-
const trimmedHost = host.trim();
501+
const trimmedHost = state.sshHost.trim();
494502
state.runtimeString = mode === "ssh" ? (trimmedHost ? `ssh ${trimmedHost}` : "ssh") : undefined;
495503
});
496504

497-
const setTrunkBranch = mock((branch: string) => {
498-
state.trunkBranch = branch;
505+
const setDefaultRuntimeMode = mock((mode: RuntimeMode) => {
506+
state.defaultRuntimeMode = mode;
507+
state.runtimeMode = mode;
508+
const trimmedHost = state.sshHost.trim();
509+
state.runtimeString = mode === "ssh" ? (trimmedHost ? `ssh ${trimmedHost}` : "ssh") : undefined;
499510
});
500511

501-
const getRuntimeString = mock(() => state.runtimeString);
512+
const setSshHost = mock((host: string) => {
513+
state.sshHost = host;
514+
});
502515

503516
return {
504517
state,
505-
setRuntimeOptions,
518+
setRuntimeMode,
519+
setDefaultRuntimeMode,
520+
setSshHost,
506521
setTrunkBranch,
507522
getRuntimeString,
508523
snapshot(): {
509524
settings: DraftWorkspaceSettings;
510-
setRuntimeOptions: typeof setRuntimeOptions;
525+
setRuntimeMode: typeof setRuntimeMode;
526+
setDefaultRuntimeMode: typeof setDefaultRuntimeMode;
527+
setSshHost: typeof setSshHost;
511528
setTrunkBranch: typeof setTrunkBranch;
512529
getRuntimeString: typeof getRuntimeString;
513530
} {
@@ -516,12 +533,15 @@ function createDraftSettingsHarness(
516533
thinkingLevel: "medium",
517534
mode: "exec",
518535
runtimeMode: state.runtimeMode,
536+
defaultRuntimeMode: state.defaultRuntimeMode,
519537
sshHost: state.sshHost,
520538
trunkBranch: state.trunkBranch,
521539
};
522540
return {
523541
settings,
524-
setRuntimeOptions,
542+
setRuntimeMode,
543+
setDefaultRuntimeMode,
544+
setSshHost,
525545
setTrunkBranch,
526546
getRuntimeString,
527547
};

src/browser/components/ChatInput/useCreationWorkspace.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,14 @@ interface UseCreationWorkspaceReturn {
4646
trunkBranch: string;
4747
setTrunkBranch: (branch: string) => void;
4848
runtimeMode: RuntimeMode;
49+
defaultRuntimeMode: RuntimeMode;
4950
sshHost: string;
50-
setRuntimeOptions: (mode: RuntimeMode, host: string) => void;
51+
/** Set the currently selected runtime mode (does not persist) */
52+
setRuntimeMode: (mode: RuntimeMode) => void;
53+
/** Set the default runtime mode for this project (persists via checkbox) */
54+
setDefaultRuntimeMode: (mode: RuntimeMode) => void;
55+
/** Set the SSH host (persisted separately from runtime mode) */
56+
setSshHost: (host: string) => void;
5157
toast: Toast | null;
5258
setToast: (toast: Toast | null) => void;
5359
isSending: boolean;
@@ -72,8 +78,14 @@ export function useCreationWorkspace({
7278
const [isSending, setIsSending] = useState(false);
7379

7480
// Centralized draft workspace settings with automatic persistence
75-
const { settings, setRuntimeOptions, setTrunkBranch, getRuntimeString } =
76-
useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk);
81+
const {
82+
settings,
83+
setRuntimeMode,
84+
setDefaultRuntimeMode,
85+
setSshHost,
86+
setTrunkBranch,
87+
getRuntimeString,
88+
} = useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk);
7789

7890
// Get send options from shared hook (uses project-scoped storage key)
7991
const sendMessageOptions = useSendMessageOptions(getProjectScopeId(projectPath));
@@ -180,8 +192,11 @@ export function useCreationWorkspace({
180192
trunkBranch: settings.trunkBranch,
181193
setTrunkBranch,
182194
runtimeMode: settings.runtimeMode,
195+
defaultRuntimeMode: settings.defaultRuntimeMode,
183196
sshHost: settings.sshHost,
184-
setRuntimeOptions,
197+
setRuntimeMode,
198+
setDefaultRuntimeMode,
199+
setSshHost,
185200
toast,
186201
setToast,
187202
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

0 commit comments

Comments
 (0)