Skip to content

Commit eaa56ed

Browse files
ibetitsmikeammar-agent
authored andcommitted
🤖 refactor: add Local/Worktree runtime distinction
- Add explicit 'worktree' RuntimeMode alongside 'local' and 'ssh' - Local runtime uses project directory directly (no isolation) - Worktree runtime creates isolated git worktrees (existing behavior) - Extract LocalBaseRuntime for shared exec/file operations - Update UI to show Local/Worktree/SSH options in creation controls - Maintain backward compatibility: legacy local+srcBaseDir treated as worktree _Generated with mux_
1 parent 894a0d4 commit eaa56ed

23 files changed

+1247
-770
lines changed

src/browser/components/ChatInput/CreationControls.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,18 @@ interface CreationControlsProps {
1515

1616
/**
1717
* Additional controls shown only during workspace creation
18-
* - Trunk branch selector (which branch to fork from)
19-
* - Runtime mode (local vs SSH)
18+
* - Trunk branch selector (which branch to fork from) - hidden for Local runtime
19+
* - Runtime mode (Local, Worktree, SSH)
2020
*/
2121
export function CreationControls(props: CreationControlsProps) {
22+
// Local runtime doesn't need a trunk branch selector (uses project dir as-is)
23+
const showTrunkBranchSelector =
24+
props.branches.length > 0 && props.runtimeMode !== RUNTIME_MODE.LOCAL;
25+
2226
return (
2327
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
24-
{/* Trunk Branch Selector */}
25-
{props.branches.length > 0 && (
28+
{/* Trunk Branch Selector - hidden for Local runtime */}
29+
{showTrunkBranchSelector && (
2630
<div
2731
className="flex items-center gap-1"
2832
data-component="TrunkBranchGroup"
@@ -53,11 +57,13 @@ export function CreationControls(props: CreationControlsProps) {
5357
value={props.runtimeMode}
5458
options={[
5559
{ value: RUNTIME_MODE.LOCAL, label: "Local" },
60+
{ value: RUNTIME_MODE.WORKTREE, label: "Worktree" },
5661
{ value: RUNTIME_MODE.SSH, label: "SSH" },
5762
]}
5863
onChange={(newMode) => {
5964
const mode = newMode as RuntimeMode;
60-
props.onRuntimeChange(mode, mode === RUNTIME_MODE.LOCAL ? "" : props.sshHost);
65+
// Clear SSH host when switching away from SSH
66+
props.onRuntimeChange(mode, mode === RUNTIME_MODE.SSH ? props.sshHost : "");
6167
}}
6268
disabled={props.disabled}
6369
aria-label="Runtime mode"
@@ -77,8 +83,10 @@ export function CreationControls(props: CreationControlsProps) {
7783
<Tooltip className="tooltip" align="center" width="wide">
7884
<strong>Runtime:</strong>
7985
<br />
80-
• Local: git worktree in ~/.mux/src
81-
<br />• SSH: remote clone in ~/mux on SSH host
86+
• Local: work directly in project directory (no isolation)
87+
<br />
88+
• Worktree: git worktree in ~/.mux/src (recommended)
89+
<br />• SSH: remote clone on SSH host
8290
</Tooltip>
8391
</TooltipWrapper>
8492
</div>
Lines changed: 113 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from "react";
22
import { cn } from "@/common/lib/utils";
33
import type { RuntimeConfig } from "@/common/types/runtime";
4-
import { isSSHRuntime } from "@/common/types/runtime";
4+
import { isSSHRuntime, isWorktreeRuntime, isLocalProjectRuntime } from "@/common/types/runtime";
55
import { extractSshHostname } from "@/browser/utils/ui/runtimeBadge";
66
import { TooltipWrapper, Tooltip } from "./Tooltip";
77

@@ -10,47 +10,122 @@ interface RuntimeBadgeProps {
1010
className?: string;
1111
}
1212

13+
/** Server rack icon for SSH runtime */
14+
function SSHIcon() {
15+
return (
16+
<svg
17+
width="12"
18+
height="12"
19+
viewBox="0 0 16 16"
20+
fill="none"
21+
stroke="currentColor"
22+
strokeWidth="1.5"
23+
strokeLinecap="round"
24+
strokeLinejoin="round"
25+
aria-label="SSH Runtime"
26+
>
27+
<rect x="2" y="2" width="12" height="4" rx="1" />
28+
<rect x="2" y="10" width="12" height="4" rx="1" />
29+
<line x1="5" y1="4" x2="5" y2="4" />
30+
<line x1="5" y1="12" x2="5" y2="12" />
31+
</svg>
32+
);
33+
}
34+
35+
/** Git branch icon for worktree runtime */
36+
function WorktreeIcon() {
37+
return (
38+
<svg
39+
width="12"
40+
height="12"
41+
viewBox="0 0 16 16"
42+
fill="none"
43+
stroke="currentColor"
44+
strokeWidth="1.5"
45+
strokeLinecap="round"
46+
strokeLinejoin="round"
47+
aria-label="Worktree Runtime"
48+
>
49+
{/* Git branch icon */}
50+
<circle cx="5" cy="4" r="2" />
51+
<circle cx="11" cy="4" r="2" />
52+
<circle cx="5" cy="12" r="2" />
53+
<line x1="5" y1="6" x2="5" y2="10" />
54+
<path d="M5 8 C 5 4 11 8 11 6" />
55+
</svg>
56+
);
57+
}
58+
59+
/** Folder icon for local project-dir runtime (reserved for future use) */
60+
function _LocalIcon() {
61+
return (
62+
<svg
63+
width="12"
64+
height="12"
65+
viewBox="0 0 16 16"
66+
fill="none"
67+
stroke="currentColor"
68+
strokeWidth="1.5"
69+
strokeLinecap="round"
70+
strokeLinejoin="round"
71+
aria-label="Local Runtime"
72+
>
73+
{/* Folder icon */}
74+
<path d="M2 4 L2 13 L14 13 L14 5 L8 5 L7 3 L2 3 L2 4" />
75+
</svg>
76+
);
77+
}
78+
1379
/**
14-
* Badge to display SSH runtime information.
15-
* Shows icon-only badge for SSH runtimes with hostname in tooltip.
80+
* Badge to display runtime type information.
81+
* Shows icon-only badge with tooltip describing the runtime type.
82+
* - SSH: server icon with hostname
83+
* - Worktree: git branch icon (isolated worktree)
84+
* - Local: folder icon (project directory, no badge shown by default)
1685
*/
1786
export function RuntimeBadge({ runtimeConfig, className }: RuntimeBadgeProps) {
18-
const hostname = extractSshHostname(runtimeConfig);
19-
20-
if (!hostname) {
21-
return null;
87+
// SSH runtime: show server icon with hostname
88+
if (isSSHRuntime(runtimeConfig)) {
89+
const hostname = extractSshHostname(runtimeConfig);
90+
return (
91+
<TooltipWrapper inline>
92+
<span
93+
className={cn(
94+
"inline-flex items-center rounded px-1 py-0.5",
95+
"bg-accent/10 text-accent border border-accent/30",
96+
className
97+
)}
98+
>
99+
<SSHIcon />
100+
</span>
101+
<Tooltip align="right">SSH: {hostname ?? runtimeConfig.host}</Tooltip>
102+
</TooltipWrapper>
103+
);
22104
}
23105

24-
return (
25-
<TooltipWrapper inline>
26-
<span
27-
className={cn(
28-
"inline-flex items-center rounded px-1 py-0.5",
29-
"bg-accent/10 text-accent border border-accent/30",
30-
className
31-
)}
32-
>
33-
<svg
34-
width="12"
35-
height="12"
36-
viewBox="0 0 16 16"
37-
fill="none"
38-
stroke="currentColor"
39-
strokeWidth="1.5"
40-
strokeLinecap="round"
41-
strokeLinejoin="round"
42-
aria-label="SSH Runtime"
106+
// Worktree runtime: show git branch icon
107+
if (isWorktreeRuntime(runtimeConfig)) {
108+
return (
109+
<TooltipWrapper inline>
110+
<span
111+
className={cn(
112+
"inline-flex items-center rounded px-1 py-0.5",
113+
"bg-muted/50 text-muted-foreground border border-muted",
114+
className
115+
)}
43116
>
44-
{/* Server rack icon */}
45-
<rect x="2" y="2" width="12" height="4" rx="1" />
46-
<rect x="2" y="10" width="12" height="4" rx="1" />
47-
<line x1="5" y1="4" x2="5" y2="4" />
48-
<line x1="5" y1="12" x2="5" y2="12" />
49-
</svg>
50-
</span>
51-
<Tooltip align="right">
52-
SSH: {isSSHRuntime(runtimeConfig) ? runtimeConfig.host : hostname}
53-
</Tooltip>
54-
</TooltipWrapper>
55-
);
117+
<WorktreeIcon />
118+
</span>
119+
<Tooltip align="right">Worktree: isolated git worktree</Tooltip>
120+
</TooltipWrapper>
121+
);
122+
}
123+
124+
// Local project-dir runtime: don't show badge (it's the simplest/default)
125+
// Could optionally show LocalIcon if we want visibility
126+
if (isLocalProjectRuntime(runtimeConfig)) {
127+
return null; // No badge for simple local runtimes
128+
}
129+
130+
return null;
56131
}

src/browser/utils/chatCommands.test.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,21 @@ beforeEach(() => {
1313
describe("parseRuntimeString", () => {
1414
const workspaceName = "test-workspace";
1515

16-
test("returns undefined for undefined runtime (default to local)", () => {
16+
test("returns undefined for undefined runtime (default to worktree)", () => {
1717
expect(parseRuntimeString(undefined, workspaceName)).toBeUndefined();
1818
});
1919

20-
test("returns undefined for explicit 'local' runtime", () => {
21-
expect(parseRuntimeString("local", workspaceName)).toBeUndefined();
22-
expect(parseRuntimeString("LOCAL", workspaceName)).toBeUndefined();
23-
expect(parseRuntimeString(" local ", workspaceName)).toBeUndefined();
20+
test("returns undefined for explicit 'worktree' runtime", () => {
21+
expect(parseRuntimeString("worktree", workspaceName)).toBeUndefined();
22+
expect(parseRuntimeString("WORKTREE", workspaceName)).toBeUndefined();
23+
expect(parseRuntimeString(" worktree ", workspaceName)).toBeUndefined();
24+
});
25+
26+
test("returns local config for explicit 'local' runtime", () => {
27+
// "local" now returns project-dir runtime config (no srcBaseDir)
28+
expect(parseRuntimeString("local", workspaceName)).toEqual({ type: "local" });
29+
expect(parseRuntimeString("LOCAL", workspaceName)).toEqual({ type: "local" });
30+
expect(parseRuntimeString(" local ", workspaceName)).toEqual({ type: "local" });
2431
});
2532

2633
test("parses valid SSH runtime", () => {
@@ -87,10 +94,10 @@ describe("parseRuntimeString", () => {
8794

8895
test("throws error for unknown runtime type", () => {
8996
expect(() => parseRuntimeString("docker", workspaceName)).toThrow(
90-
"Unknown runtime type: 'docker'"
97+
"Unknown runtime type: 'docker'. Use 'ssh <host>', 'worktree', or 'local'"
9198
);
9299
expect(() => parseRuntimeString("remote", workspaceName)).toThrow(
93-
"Unknown runtime type: 'remote'"
100+
"Unknown runtime type: 'remote'. Use 'ssh <host>', 'worktree', or 'local'"
94101
);
95102
});
96103
});

src/browser/utils/chatCommands.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -404,22 +404,30 @@ async function handleForkCommand(
404404
* Parse runtime string from -r flag into RuntimeConfig for backend
405405
* Supports formats:
406406
* - "ssh <host>" or "ssh <user@host>" -> SSH runtime
407-
* - "local" -> Local runtime (explicit)
408-
* - undefined -> Local runtime (default)
407+
* - "worktree" -> Worktree runtime (git worktrees)
408+
* - "local" -> Local runtime (project-dir, no isolation)
409+
* - undefined -> Worktree runtime (default)
409410
*/
410411
export function parseRuntimeString(
411412
runtime: string | undefined,
412413
_workspaceName: string
413414
): RuntimeConfig | undefined {
414415
if (!runtime) {
415-
return undefined; // Default to local (backend decides)
416+
return undefined; // Default to worktree (backend decides)
416417
}
417418

418419
const trimmed = runtime.trim();
419420
const lowerTrimmed = trimmed.toLowerCase();
420421

422+
// Worktree runtime (explicit or default)
423+
if (lowerTrimmed === RUNTIME_MODE.WORKTREE) {
424+
return undefined; // Explicit worktree - let backend use default
425+
}
426+
427+
// Local runtime (project-dir, no isolation)
421428
if (lowerTrimmed === RUNTIME_MODE.LOCAL) {
422-
return undefined; // Explicit local - let backend use default
429+
// Return "local" type without srcBaseDir to indicate project-dir runtime
430+
return { type: RUNTIME_MODE.LOCAL };
423431
}
424432

425433
// Parse "ssh <host>" or "ssh <user@host>" format
@@ -439,7 +447,7 @@ export function parseRuntimeString(
439447
};
440448
}
441449

442-
throw new Error(`Unknown runtime type: '${runtime}'. Use 'ssh <host>' or 'local'`);
450+
throw new Error(`Unknown runtime type: '${runtime}'. Use 'ssh <host>', 'worktree', or 'local'`);
443451
}
444452

445453
export interface CreateWorkspaceOptions {

src/common/constants/workspace.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { RuntimeConfig } from "@/common/types/runtime";
22

33
/**
4-
* Default runtime configuration for local workspaces
5-
* Used when no runtime config is specified
4+
* Default runtime configuration for worktree workspaces.
5+
* Uses git worktrees for workspace isolation.
6+
* Used when no runtime config is specified.
67
*/
78
export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = {
8-
type: "local",
9+
type: "worktree",
910
srcBaseDir: "~/.mux/src",
1011
} as const;

src/common/types/runtime.test.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,16 @@ describe("parseRuntimeModeAndHost", () => {
2323
});
2424
});
2525

26-
it("defaults to local for undefined", () => {
26+
it("defaults to worktree for undefined", () => {
2727
expect(parseRuntimeModeAndHost(undefined)).toEqual({
28-
mode: "local",
28+
mode: "worktree",
2929
host: "",
3030
});
3131
});
3232

33-
it("defaults to local for null", () => {
33+
it("defaults to worktree for null", () => {
3434
expect(parseRuntimeModeAndHost(null)).toEqual({
35-
mode: "local",
35+
mode: "worktree",
3636
host: "",
3737
});
3838
});
@@ -47,8 +47,12 @@ describe("buildRuntimeString", () => {
4747
expect(buildRuntimeString("ssh", "")).toBe("ssh");
4848
});
4949

50-
it("returns undefined for local mode", () => {
51-
expect(buildRuntimeString("local", "")).toBeUndefined();
50+
it("returns 'local' for local mode", () => {
51+
expect(buildRuntimeString("local", "")).toBe("local");
52+
});
53+
54+
it("returns undefined for worktree mode (default)", () => {
55+
expect(buildRuntimeString("worktree", "")).toBeUndefined();
5256
});
5357

5458
it("trims whitespace from host", () => {

0 commit comments

Comments
 (0)